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..165327d983d 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.createBounded(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.tryGetOrCreateOrNull(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 52f5485b537..75f53335a12 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java @@ -278,9 +278,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. @@ -288,7 +289,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 d16ee3c4eca..7150d555b2d 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.createBounded(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 1a181bee77c..70c7a4b3e73 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.createBounded(D2Counter.class, CAPACITY); hashMap = new HashMap<>(CAPACITY); k1s = SOURCE_K1; k2s = SOURCE_K2; diff --git a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java index 36f824c5aad..2886a54ea9a 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java @@ -242,6 +242,8 @@ public void pollute() { @Setup(Level.Trial) public void setUp() { + BenchmarkUtils.polluteHashDispatch(); + hashMap = new HashMap<>(); fill(hashMap); synchronizedHashMap = Collections.synchronizedMap(new HashMap<>(hashMap)); diff --git a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java index 04cb4c43034..babc56c070e 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java @@ -126,6 +126,8 @@ public void pollute() { @Setup(Level.Trial) public void setUp() { + BenchmarkUtils.polluteHashDispatch(); + hashSet = new HashSet<>(Arrays.asList(ELEMENTS)); synchronizedSet = Collections.synchronizedSet(new HashSet<>(hashSet)); treeSet = new TreeSet<>(Arrays.asList(ELEMENTS)); 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..481632abc96 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 + * #tryGetOrCreateOrNull} 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 tryGetOrCreateOrNull} 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.createBounded}. 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 #tryGetOrCreateOrNull} returns {@code null}). */ @Nonnull public static > D1 createFixed( @@ -236,12 +257,38 @@ public TEntry get(@Nullable K key) { /** * Existing entry for {@code key}, or a freshly {@link CreateStrategy#create created} + inserted - * 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. + * one, wrapped in a {@link Maybe}. A growable table's {@link Maybe} is always present; a fixed + * one's is absent 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. + * + *

    Delegates to {@link #tryGetOrCreateOrNull} as the sole {@link Maybe#of} call site -- see + * {@link Maybe}'s class javadoc for why that shape is required to stay allocation-free. + */ + @Nonnull + public Maybe tryGetOrCreate( + @Nullable K key, @Nonnull CreateStrategy createStrat) { + return Maybe.of(tryGetOrCreateOrNull(key, createStrat)); + } + + /** + * Low-level, {@code null}-returning form of {@link #tryGetOrCreate}. Prefer the {@link Maybe} + * form above for new call sites; this one remains as an escape hatch for callers where the + * {@link Maybe} allocation-free contract doesn't fit or that pre-date it. Under-promising + * refusal here costs an NPE at the cap; over-promising it costs a redundant null check on a + * growable table -- so it errs toward {@code try}. + * + * @param key key to look up + * @param createStrat creates an entry when the key is absent + * @return the existing or created entry, or {@code null} when a fixed table is full and {@code + * key} is absent */ @Nullable - public TEntry getOrCreate(@Nullable K key, @Nonnull CreateStrategy createStrat) { + public TEntry tryGetOrCreateOrNull( + @Nullable K key, @Nonnull CreateStrategy createStrat) { final TEntry existing = get(key); if (existing != null) { return existing; @@ -300,7 +347,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 +413,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 #tryGetOrCreateOrNull} returns {@code null}). */ @Nonnull public static > D2 createFixed( @@ -425,11 +472,31 @@ public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { } /** - * Two-key analogue of {@link D1#getOrCreate}: growable never returns {@code null}; fixed - * returns {@code null} when full and {@code (key1, key2)} is absent. + * Two-key analogue of {@link D1#tryGetOrCreate}: {@link Maybe}-wrapped form, delegating to + * {@link #tryGetOrCreateOrNull} as the sole {@link Maybe#of} call site. Growable's {@link + * Maybe} is always present; fixed's is absent when full and {@code (key1, key2)} is absent. + */ + @Nonnull + public Maybe tryGetOrCreate( + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull CreateStrategy2 createStrat) { + return Maybe.of(tryGetOrCreateOrNull(key1, key2, createStrat)); + } + + /** + * Two-key analogue of {@link D1#tryGetOrCreateOrNull}: low-level, {@code null}-returning form + * of {@link #tryGetOrCreate}. Growable never returns {@code null}; fixed returns {@code null} + * when full and {@code (key1, key2)} is absent. + * + * @param key1 first key component + * @param key2 second key component + * @param createStrat creates an entry when the key pair is absent + * @return the existing or created entry, or {@code null} when a fixed table is full and the key + * pair is absent */ @Nullable - public TEntry getOrCreate( + public TEntry tryGetOrCreateOrNull( @Nullable K1 key1, @Nullable K2 key2, @Nonnull CreateStrategy2 createStrat) { @@ -486,7 +553,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 +590,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 +771,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..4c20451cb22 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,9 @@ import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; +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 +27,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 +64,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 +75,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 +97,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 #tryGetOrCreateOrNull} 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#createBounded(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 +129,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 +152,273 @@ 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 #tryGetOrCreateOrNull} 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#createBounded(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.createBounded(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. + * + * @param entryClass type token pinning {@code TEntry} for inference; not used for reflective + * allocation + * @param maxCapacity strict cap on live entries + * @return a capped, non-resizing single-key table + */ + @Nonnull + public static > D1 createBounded( + @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(); + } + + /** {@code true} once {@link #size()} has reached this table's fixed capacity. */ + public boolean isFull() { + return this.sizeManager.isFull(); } - public TEntry get(K key) { + @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; + /** + * Removes every entry matching {@code predicate}, returning {@code true} if any were removed. + */ + public boolean removeIf(@Nonnull Predicate predicate) { + return Hashtable.removeIf(this.sizeManager, this.buckets, predicate); } - public TEntry insertOrReplace(TEntry newEntry) { + /** + * 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. {@code newEntry} must also be a fresh entry, not already + * linked into this or any other table's bucket chain -- reinserting an already-linked entry + * corrupts the chain it's still part of (guarded by an assertion in {@link + * #insertHeadEntryAt(Hashtable.Entry[], int, Hashtable.Entry)}). + */ + public boolean insert(@Nonnull TEntry newEntry) { + return insertHeadEntryFor(this.sizeManager, this.buckets, newEntry.keyHash, 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 + * Returns the entry for {@code key}, building one via {@code creator} if absent -- wrapped in a + * {@link Maybe} that is absent if the key is absent and the table is at capacity. 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 #createBounded}. Decide deliberately what a refused create should do (drop the sample, + * fall back, make room); silently ignoring an absent {@link Maybe} turns the cap into data loss + * you cannot see. + * + *

    Computes the hash once, reused 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. + *

    {@code creator} must 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)}), or a future {@link #get} for that key won't find it. + * + *

    Exactly one {@link Maybe#of} call site, fed by delegating to {@link #tryGetOrCreateOrNull} + * -- see {@link Maybe}'s class javadoc for why that shape is required to stay allocation-free. + * Use {@link #tryGetOrCreateOrNull} directly only when a manual null check is genuinely more + * convenient than {@link Maybe#update}/{@link Maybe#getOrNull}. + * + * @param key key to look up + * @param creator creates an entry when the key is absent + * @return the existing or created entry, or an absent value when a new key is refused at + * capacity */ - public TEntry getOrCreate(K key, Function creator) { + @Nonnull + public Maybe tryGetOrCreate( + @Nullable K key, @Nonnull Function creator) { + return Maybe.of(tryGetOrCreateOrNull(key, creator)); + } + + /** + * Low-level, {@code null}-returning form of {@link #tryGetOrCreate}. Prefer the {@link Maybe} + * form above for new call sites; this one remains as an escape hatch for callers where the + * {@link Maybe} allocation-free contract doesn't fit (e.g. storing the result past the current + * stack frame) or that pre-date it. + */ + @Nullable + public TEntry tryGetOrCreateOrNull( + @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, not the one-call tracked + // insertHeadEntryFor(sizeManager, ...) that insert/tryInsertOrReplace use: creator runs + // between check and link and may throw, so reserving up front could leak. + 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 #tryGetOrCreateOrNull}, but evicting one entry matching {@code evictable} instead of + * refusing when the table is full -- see {@link #tryGetOrCreateOrEvictOrNull} for the + * null-returning form and the eviction/creation ordering. + */ + @Nonnull + public Maybe tryGetOrCreateOrEvict( + @Nullable K key, + @Nonnull Function creator, + @Nonnull Predicate evictable) { + return Maybe.of(tryGetOrCreateOrEvictOrNull(key, creator, evictable)); + } + + /** + * Nullable form of {@link #tryGetOrCreateOrEvict}. When full, evicts before invoking {@code + * creator}; if creation then throws, the table remains one entry smaller without leaking a + * reservation. + * + * @param key key to look up + * @param creator creates an entry when the key is absent + * @param evictable selects an entry to remove when the table is full + * @return the existing or created entry, or {@code null} when full with no evictable entry + */ + @Nullable + public TEntry tryGetOrCreateOrEvictOrNull( + @Nullable K key, + @Nonnull Function creator, + @Nonnull Predicate evictable) { + long keyHash = D1.Entry.hash(key); + for (TEntry curEntry = bucketFor(this.buckets, keyHash); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return curEntry; + } + } + // Deliberately isFull() -> evictOne -> create -> increment, not tryReserveOrEvict: creator + // runs between eviction and link and may throw, so reserving the freed slot up front could + // leak it. See tryGetOrCreateOrNull above for the non-evicting form of this reasoning. + if (this.sizeManager.isFull() && this.sizeManager.evictOne(this.buckets, evictable) == null) { + return null; + } + TEntry newEntry = creator.apply(key); + insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); + this.sizeManager.increment(); + return newEntry; } - public void forEach(Consumer consumer) { - Support.forEach(this.buckets, consumer); + public void forEach(@Nonnull Consumer consumer) { + Hashtable.forEach(this.buckets, consumer); } /** @@ -222,8 +426,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 +459,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 +490,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 +519,219 @@ 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#createBounded}: a capped table holding at most + * {@code maxCapacity} live entries, after which {@link #insert} returns {@code false} and + * {@link #tryGetOrCreateOrNull} returns {@code null}, with lookup hits still always returned. + * See {@link D1#createBounded} 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.createBounded(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. + * + * @param entryClass type token pinning {@code TEntry} for inference; not used for reflective + * allocation + * @param maxCapacity strict cap on live entries + * @return a capped, non-resizing two-key table + */ + @Nonnull + public static > D2 createBounded( + @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; + /** + * Removes every entry matching {@code predicate}, returning {@code true} if any were removed. + */ + public boolean removeIf(@Nonnull Predicate predicate) { + return Hashtable.removeIf(this.sizeManager, this.buckets, predicate); } - public TEntry insertOrReplace(TEntry newEntry) { + /** 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); + } + + /** 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#tryGetOrCreate}: returns the entry for {@code (key1, key2)}, + * building one via {@code creator} if absent -- wrapped in a {@link Maybe} that is absent if + * the pair is absent and the table is at capacity. 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)}. + * + *

    Delegates to {@link #tryGetOrCreateOrNull} as the sole {@link Maybe#of} call site. + */ + @Nonnull + public Maybe tryGetOrCreate( + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull BiFunction creator) { + return Maybe.of(tryGetOrCreateOrNull(key1, key2, creator)); } /** - * 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#tryGetOrCreateOrNull}: low-level, {@code null}-returning form + * of {@link #tryGetOrCreate}. Prefer the {@link Maybe} form above for new call sites. */ - public TEntry getOrCreate( - K1 key1, K2 key2, BiFunction creator) { + @Nullable + public TEntry tryGetOrCreateOrNull( + @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#tryGetOrCreateOrEvict}: {@link #tryGetOrCreateOrNull}, but + * evicting one entry matching {@code evictable} instead of refusing when the table is full -- + * see {@link #tryGetOrCreateOrEvictOrNull} for the null-returning form and the + * eviction/creation ordering. + */ + @Nonnull + public Maybe tryGetOrCreateOrEvict( + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull BiFunction creator, + @Nonnull Predicate evictable) { + return Maybe.of(tryGetOrCreateOrEvictOrNull(key1, key2, creator, evictable)); } - public void forEach(Consumer consumer) { - Support.forEach(this.buckets, consumer); + /** + * Escape hatch for {@link #tryGetOrCreateOrEvict} for callers that want the nullable entry + * directly. Eviction runs before {@code creator}, not after: {@code creator} may throw, so + * freeing a slot and only then attempting the fallible create keeps a thrown exception from + * ever leaving a slot double-booked. A creator that throws after a successful eviction simply + * leaves the table one entry smaller -- no corruption, just a wasted eviction. + */ + @Nullable + public TEntry tryGetOrCreateOrEvictOrNull( + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull BiFunction creator, + @Nonnull Predicate evictable) { + long keyHash = D2.Entry.hash(key1, key2); + for (TEntry curEntry = bucketFor(this.buckets, keyHash); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return curEntry; + } + } + // Deliberately isFull() -> evictOne -> create -> increment, not tryReserveOrEvict: `creator` + // runs between eviction and the link and may throw, so reserving the freed slot up front + // could leak it. See tryGetOrCreateOrNull above for the non-evicting form of this same + // reasoning. + if (this.sizeManager.isFull() && this.sizeManager.evictOne(this.buckets, evictable) == null) { + return null; + } + TEntry newEntry = creator.apply(key1, key2); + insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); + this.sizeManager.increment(); + return newEntry; + } + + public void forEach(@Nonnull Consumer consumer) { + Hashtable.forEach(this.buckets, consumer); } /** @@ -392,195 +739,1023 @@ 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. * - *

    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: + *

    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. * - *

      - *
    • 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[])}. - *
    + *

    {@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 #createBounded} + * 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. * - *

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

    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 #createBounded} + * 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. + * + *

    {@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 ~DEFAULT_LOAD_FACTOR}, i.e. entries + * per bucket) 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 #createBounded} 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)}. + * + *

    Divides in {@code double} and rounds up ({@code Math.ceil}) before handing off to {@link + * #sizeFor(int)}, rather than truncating via an {@code int} cast: truncating first can collapse + * the requested headroom away entirely for small {@code cardinalityLimit} -- e.g. {@code + * cardinalityLimit=1} at the default 0.75 load factor would truncate {@code 1/0.75 = 1.333} down + * to {@code 1} and size a 1-bucket array, a 1.0 load factor rather than the documented 0.75. + */ + 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) Math.ceil(cardinalityLimit / (double) 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}, decrementing {@code sizeManager} for each entry as it is unlinked -- before + * handing it to {@code sink}, so a sink that throws mid-drain leaves the count matching exactly + * what {@code buckets} still holds, rather than the whole reset being skipped and the cap staying + * permanently consumed. Same one-call reasoning as {@link #clear(SizeManager, + * Hashtable.Entry[])}. + */ + public static void drain( + @Nonnull SizeManager sizeManager, + @Nonnull Hashtable.Entry[] buckets, + @Nonnull Consumer sink) { + drain( + buckets, + (Consumer) + entry -> { + sizeManager.decrement(); + sink.accept(entry); + }); + } + + /** 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) { + drain( + buckets, + context, + (BiConsumer) + (ctx, entry) -> { + sizeManager.decrement(); + sink.accept(ctx, entry); + }); + } + + /** {@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); + } + + /** + * Removes every entry matching {@code predicate} from {@code buckets}, decrementing {@code + * sizeManager} once per removal, and returns {@code true} if any were removed. Delegates to + * {@link SizeManager#evictAll} for the sweep -- same full-table unlink, just the general-purpose + * removal entry point rather than the capacity-eviction one {@link #evictAll} is for. + */ + public static boolean removeIf( + @Nonnull SizeManager sizeManager, + @Nonnull Hashtable.Entry[] buckets, + @Nonnull Predicate predicate) { + return sizeManager.evictAll(buckets, predicate) > 0; + } + + /** {@link #removeIf(SizeManager, Hashtable.Entry[], Predicate)} over a {@link State}. */ + public static boolean removeIf( + @Nonnull State state, @Nonnull Predicate predicate) { + return removeIf(state.sizeManager, state.buckets, predicate); + } + + /** {@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); + } + + /** + * Variant of {@link #mutatingTableIterator(Hashtable.Entry[], int, int)} that resumes {@code + * bucket} after {@code resumeAfter} instead of at its head -- see {@link + * MutatingTableIterator#MutatingTableIterator(Hashtable.Entry[], int, int, Hashtable.Entry)}. + */ + @Nonnull + public static + MutatingTableIterator resumingTableIterator( + @Nonnull Hashtable.Entry[] buckets, + int bucket, + int endBucket, + @Nullable Hashtable.Entry resumeAfter) { + return new MutatingTableIterator(buckets, bucket, endBucket, resumeAfter); + } + + /** + * {@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; + + /** + * 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. + */ + private int cursor; + /** - * 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}). + * Predecessor of the entry the last eviction removed, within {@code cursor}'s chain -- or + * {@code null} if that entry was the bucket head or no eviction has happened yet since the last + * {@link #reset()}. Lets the next scan resume mid-chain instead of re-testing a non-evictable + * prefix on every call, which is what made a sustained eviction stream from one hot bucket + * quadratic. See {@link + * Hashtable.MutatingTableIterator#MutatingTableIterator(Hashtable.Entry[], int, int, + * Hashtable.Entry)} for what happens if this entry itself gets removed elsewhere before the + * next resume. */ - public static final Hashtable.Entry[] create(int requestedSize) { - return new Entry[sizeFor(requestedSize)]; + @Nullable private Hashtable.Entry cursorPrev; + + 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 #estimateSize()} 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 (requestedSize > MAX_BUCKETS) { - throw new IllegalArgumentException( - "requestedSize exceeds maximum bucket count (" + MAX_BUCKETS + "): " + requestedSize); + if (evictOne(buckets, evictable) == null) { + return false; } - if (requestedSize <= 1) { - return 1; + // 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; + this.cursorPrev = null; + } + + /** + * 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 -- including mid-chain, via {@link #cursorPrev} -- is + * what keeps a sustained eviction stream amortized: without it, a bucket with a non-evictable + * prefix followed by many evictable entries pays that prefix's cost again on every single + * eviction pulled from the bucket, making N evictions from one hot bucket O(N * prefix) instead + * of amortized O(chain length). With it, each call resumes right after the previous removal. + * + *

    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, this.cursorPrev); + if (evicted == null && this.cursor != 0) { + evicted = evictOneInRange(buckets, evictable, 0, this.cursor, null); + } + 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.) A full pass found no resume point. + this.cursor = (this.cursor + 1) & (buckets.length - 1); + this.cursorPrev = null; + 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, + @Nullable Entry resumeAfter) { + MutatingTableIterator iter = + resumeAfter != null + ? resumingTableIterator(buckets, startBucket, endBucket, resumeAfter) + : mutatingTableIterator(buckets, startBucket, endBucket); + while (iter.hasNext()) { + Entry candidate = iter.next(); + if (evictable.test((TEntry) candidate)) { + int bucket = iter.currentBucket(); + Entry prev = iter.currentPrev(); + iter.remove(); + this.cursor = bucket; + this.cursorPrev = prev; + 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; + this.cursorPrev = null; + 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: it keeps the array and its manager passed and stored together, so callers who hold a + * {@code State} can't accidentally pair one table's array with another's manager. Composers reach + * through it -- {@code state.buckets}, {@code state.sizeManager} -- when calling the static + * building blocks; both fields are package-private since only {@code datadog.trace.util} calls + * those building blocks directly. + * + *

    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 { + final Hashtable.Entry[] buckets; + 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 createBounded(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). */ - public static final - MutatingTableIterator mutatingTableIterator(Hashtable.Entry[] buckets) { - return new MutatingTableIterator(buckets, 0, buckets.length); + @Deprecated + @Nonnull + public static Hashtable.Entry[] create(int requestedSize) { + return Hashtable.create(requestedSize); } /** - * 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. + * 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)}. * - * @param startBucket inclusive lower bound; must be in {@code [0, buckets.length]}. - * @param endBucket exclusive upper bound; must be in {@code [startBucket, buckets.length]}. + *

    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. + */ + @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)); + } + + /** + * 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. + * + * @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)}. */ - public static final + @Deprecated + static int sizeFor(int requestedSize) { + return Hashtable.sizeFor(requestedSize); + } + + /** + * @deprecated use {@link Hashtable#clear(Hashtable.Entry[])}. + */ + @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 +1774,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 +1790,7 @@ public boolean hasNext() { @Override @SuppressWarnings("unchecked") + @Nonnull public TEntry next() { Hashtable.Entry cur = this.nextEntry; if (cur == null) { @@ -661,11 +1837,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 +1871,7 @@ public boolean hasNext() { @Override @SuppressWarnings("unchecked") + @Nonnull public TEntry next() { Hashtable.Entry curEntry = this.nextEntry; if (curEntry == null) { @@ -739,11 +1916,13 @@ 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(); } + assert replacementEntry.next() == null + : "Entry already linked -- inserting the same Entry instance twice corrupts the chain"; Hashtable.Entry oldNext = oldCurEntry.next(); replacementEntry.setNext(oldNext); @@ -759,10 +1938,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 +1995,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( @@ -836,6 +2015,35 @@ public static final class MutatingTableIterator seekFromBucket(startBucket); } + /** + * Resumes a walk of {@code bucket} immediately after {@code resumeAfter}, instead of from the + * bucket's head -- so a caller that remembers where it last stopped inside a chain doesn't pay + * to re-test the entries before that point. {@code resumeAfter}'s own successor is read lazily + * ({@code resumeAfter.next()}), so any unrelated removal elsewhere in the chain since it was + * saved is picked up correctly. The one caveat: if {@code resumeAfter} itself was + * removed in the meantime, its {@code next()} now reads {@code null} (removal detaches a node + * from its own successor), so this walk sees {@code bucket} as exhausted and moves on to {@code + * bucket + 1} -- under-scanning {@code bucket} for this one pass rather than corrupting + * anything. A resumable sweep that revisits every bucket over time (e.g. {@link + * SizeManager#evictOne}'s cursor) self-heals that on its next lap. + */ + MutatingTableIterator( + @Nonnull Hashtable.Entry[] buckets, + int bucket, + int endBucket, + @Nullable Hashtable.Entry resumeAfter) { + this.buckets = buckets; + this.endBucket = endBucket; + Hashtable.Entry head = (resumeAfter == null) ? buckets[bucket] : resumeAfter.next(); + if (head != null) { + this.nextBucketIndex = bucket; + this.nextPrevEntry = resumeAfter; + this.nextEntry = head; + } else { + seekFromBucket(bucket + 1); + } + } + /** * Bucket index of the entry last returned by {@link #next()}, or {@code -1} if {@code next} has * not yet been called or the most recent call was {@link #remove()}. Useful for callers driving @@ -846,6 +2054,17 @@ public int currentBucket() { return this.curBucketIndex; } + /** + * Predecessor of the entry last returned by {@link #next()} within its bucket, or {@code null} + * if it was the bucket head. Paired with {@link #currentBucket()}, this is enough for a caller + * to resume this exact chain position later via the {@code resumeAfter} constructor, without + * re-walking the entries already tested. + */ + @Nullable + public Hashtable.Entry currentPrev() { + return this.curPrevEntry; + } + @Override public boolean hasNext() { return this.nextEntry != null; @@ -853,6 +2072,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/main/java/datadog/trace/util/Maybe.java b/internal-api/src/main/java/datadog/trace/util/Maybe.java index 4aec3fe290e..49ae9c86926 100644 --- a/internal-api/src/main/java/datadog/trace/util/Maybe.java +++ b/internal-api/src/main/java/datadog/trace/util/Maybe.java @@ -89,10 +89,9 @@ public T getOrNull() { } /** - * Primary intended usage: a guard in front of mutation, e.g. {@code - * table.tryGetOrCreateAsTry(key, FooEntry::new).update(FooEntry::inc)}. No-op if the operation - * was refused (table full) rather than throwing or requiring the caller to branch on {@link - * #isPresent()} first. + * Primary intended usage: a guard in front of mutation, e.g. {@code table.tryGetOrCreate(key, + * FooEntry::new).update(FooEntry::inc)}. No-op if the operation was refused (table full) rather + * than throwing or requiring the caller to branch on {@link #isPresent()} first. */ public void update(Consumer mutator) { if (value != null) { @@ -118,8 +117,8 @@ public void update(C context, BiConsumer mutator) { * needs one caller-supplied number (e.g. a duration or count) and boxing it into a captured * {@code Long}/generic-context object would be the actual per-call allocation. This exists so a * table wrapping a fallible lookup in {@code Maybe} pays for this shape once, here, instead of - * once per mutator-flavor per table type -- see {@code Hashtable#tryGetOrUpdate}'s {@code - * ObjLongConsumer} overload for the caller-side problem this replaces. + * once per mutator-flavor per table type -- a fused, per-table {@code ObjLongConsumer} overload + * would otherwise be needed for the same accumulate-a-count shape. * *

    Deliberately the only primitive-context overload. An {@code int}/{@code * double}/{@code boolean} sibling was tried and reverted: Java's overload resolution can pick 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..56594c292fc 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.tryGetOrCreateOrNull( "foo", k -> { createCount[0]++; @@ -209,7 +209,7 @@ void getOrCreateOnHitSkipsCreator() { table.insert(seeded); int[] createCount = {0}; StringIntEntry got = - table.getOrCreate( + table.tryGetOrCreateOrNull( "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.tryGetOrCreateOrNull("k" + i, k -> new StringIntEntry(k, 0)); assertNotNull(e); } assertEquals(50, table.size()); @@ -246,15 +246,36 @@ 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.tryGetOrCreateOrNull("a", k -> new StringIntEntry(k, 1))); + assertNotNull(table.tryGetOrCreateOrNull("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.tryGetOrCreateOrNull("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.tryGetOrCreateOrNull("a", k -> new StringIntEntry(k, 99))); + } + + @Test + void fixedGetOrCreateAsMaybeIsAbsentWhenFullButStillReturnsHits() { + FlatHashtable.D1 table = fixed(2); + table.tryGetOrCreate("a", k -> new StringIntEntry(k, 1)); + table.tryGetOrCreate("b", k -> new StringIntEntry(k, 2)); + + assertFalse(table.tryGetOrCreate("c", k -> new StringIntEntry(k, 3)).isPresent()); + + Maybe hit = table.tryGetOrCreate("a", k -> new StringIntEntry(k, 99)); + assertEquals(1, hit.getOrNull().value, "existing entry is still returned even at capacity"); + } + + @Test + void growableGetOrCreateAsMaybeIsAlwaysPresent() { + FlatHashtable.D1 table = growable(1); + for (int i = 0; i < 50; i++) { + assertTrue(table.tryGetOrCreate("k" + i, k -> new StringIntEntry(k, 0)).isPresent()); + } + assertEquals(50, table.size()); } @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..948501185cd 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.tryGetOrCreateOrNull( "foo", 1, (k1, k2) -> { @@ -190,7 +190,7 @@ void getOrCreateOnHitSkipsCreator() { table.insert(seeded); int[] createCount = {0}; PairEntry got = - table.getOrCreate( + table.tryGetOrCreateOrNull( "foo", 1, (k1, k2) -> { @@ -217,13 +217,35 @@ 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.tryGetOrCreateOrNull("a", 1, (k1, k2) -> new PairEntry(k1, k2, 1))); + assertNotNull(table.tryGetOrCreateOrNull("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.tryGetOrCreateOrNull("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.tryGetOrCreateOrNull("a", 1, (k1, k2) -> new PairEntry(k1, k2, 99))); + } + + @Test + void fixedGetOrCreateAsMaybeIsAbsentWhenFullButStillReturnsHits() { + FlatHashtable.D2 table = fixed(2); + table.tryGetOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 1)); + table.tryGetOrCreate("b", 2, (k1, k2) -> new PairEntry(k1, k2, 2)); + + assertFalse(table.tryGetOrCreate("c", 3, (k1, k2) -> new PairEntry(k1, k2, 3)).isPresent()); + + Maybe hit = table.tryGetOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 99)); + assertEquals(1, hit.getOrNull().value, "existing entry is still returned even at capacity"); + } + + @Test + void growableGetOrCreateAsMaybeIsAlwaysPresent() { + FlatHashtable.D2 table = growable(1); + for (int i = 0; i < 50; i++) { + Maybe maybe = table.tryGetOrCreate("k", i, (k1, k2) -> new PairEntry(k1, k2, k2)); + assertTrue(maybe.isPresent()); + } + assertEquals(50, table.size()); } @Test @@ -258,7 +280,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.tryGetOrCreateOrNull("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..6bfc168f17e 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,32 @@ 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.assertThrows; +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.createBounded(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.createBounded(StringIntEntry.class, 8); StringIntEntry e = new StringIntEntry("foo", 1); table.insert(e); assertEquals(1, table.size()); @@ -38,7 +44,8 @@ void keyExposesTheConstructionKey() { @Test void multipleInsertsRetrievableSeparately() { - Hashtable.D1 table = new Hashtable.D1<>(16); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 16); StringIntEntry a = new StringIntEntry("alpha", 1); StringIntEntry b = new StringIntEntry("beta", 2); StringIntEntry c = new StringIntEntry("gamma", 3); @@ -53,7 +60,8 @@ void multipleInsertsRetrievableSeparately() { @Test void inPlaceMutationVisibleViaSubsequentGet() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); table.insert(new StringIntEntry("counter", 0)); for (int i = 0; i < 10; i++) { StringIntEntry e = table.get("counter"); @@ -64,7 +72,8 @@ void inPlaceMutationVisibleViaSubsequentGet() { @Test void removeUnlinksEntryAndDecrementsSize() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); assertEquals(2, table.size()); @@ -79,28 +88,146 @@ void removeUnlinksEntryAndDecrementsSize() { @Test void removeNonexistentReturnsNullAndDoesNotChangeSize() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = + Hashtable.D1.createBounded(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 removeIfUnlinksMatchingEntriesAndDecrementsSize() { + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + table.insert(new StringIntEntry("c", 3)); + + assertTrue(table.removeIf(e -> e.value % 2 == 1)); + + assertEquals(1, table.size()); + assertNull(table.get("a")); + assertNotNull(table.get("b")); + assertNull(table.get("c")); + } + + @Test + void removeIfReturnsFalseAndLeavesTableUntouchedWhenNothingMatches() { + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); + table.insert(new StringIntEntry("a", 1)); + + assertFalse(table.removeIf(e -> false)); + + assertEquals(1, table.size()); + assertNotNull(table.get("a")); + } + + @Test + void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() { + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); + + Maybe created = + table.tryGetOrCreateOrEvict("a", k -> new StringIntEntry(k, 1), e -> true); + + assertTrue(created.isPresent()); + assertEquals(1, table.size()); + assertSame(created.getOrNull(), table.get("a")); + } + + @Test + void tryGetOrCreateOrEvictReturnsExistingEntryOnHitWithoutEvicting() { + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 1); + StringIntEntry a = table.tryGetOrCreateOrNull("a", k -> new StringIntEntry(k, 1)); + + Maybe got = + table.tryGetOrCreateOrEvict( + "a", + k -> { + throw new AssertionError("creator must not run on a hit"); + }, + e -> { + throw new AssertionError("evictable must not run on a hit"); + }); + + assertSame(a, got.getOrNull()); + assertEquals(1, table.size()); + } + + @Test + void tryGetOrCreateOrEvictEvictsWhenFullAndInsertsNewEntry() { + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 1); + table.tryGetOrCreateOrNull("old", k -> new StringIntEntry(k, 1)); + assertTrue(table.isFull()); + + Maybe created = + table.tryGetOrCreateOrEvict("new", k -> new StringIntEntry(k, 2), e -> true); + + assertTrue(created.isPresent()); + assertEquals(1, table.size()); + assertNull(table.get("old")); + assertSame(created.getOrNull(), table.get("new")); + } + + @Test + void tryGetOrCreateOrEvictOrNullRefusesWhenFullAndNothingEvictable() { + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 1); + table.tryGetOrCreateOrNull("old", k -> new StringIntEntry(k, 1)); + + StringIntEntry result = + table.tryGetOrCreateOrEvictOrNull("new", k -> new StringIntEntry(k, 2), e -> false); + + assertNull(result); + assertEquals(1, table.size()); + assertNotNull(table.get("old")); + assertNull(table.get("new")); + } + + @Test + void tryGetOrCreateOrEvictOrNullEvictionRunsBeforeThrowingCreator() { + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 1); + table.tryGetOrCreateOrNull("old", k -> new StringIntEntry(k, 1)); + + assertThrows( + RuntimeException.class, + () -> + table.tryGetOrCreateOrEvictOrNull( + "new", + k -> { + throw new RuntimeException("boom"); + }, + e -> true)); + + // Eviction already happened before the creator threw: the table is left one entry smaller, + // not corrupted or double-booked. + assertEquals(0, table.size()); + assertNull(table.get("old")); + assertNull(table.get("new")); + } + + @Test + void tryInsertOrReplaceInsertsThenReplacesWithoutGrowing() { + Hashtable.D1 table = + Hashtable.D1.createBounded(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.createBounded(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); table.clear(); @@ -113,7 +240,8 @@ void clearEmptiesTheTable() { @Test void forEachVisitsEveryInsertedEntry() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); table.insert(new StringIntEntry("c", 3)); @@ -127,7 +255,8 @@ void forEachVisitsEveryInsertedEntry() { @Test void forEachWithContextPassesContextToConsumer() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 10)); table.insert(new StringIntEntry("b", 20)); table.insert(new StringIntEntry("c", 30)); @@ -141,7 +270,8 @@ void forEachWithContextPassesContextToConsumer() { @Test void forEachWithContextOnEmptyTableDoesNothing() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); Map seen = new HashMap<>(); table.forEach(seen, (ctx, e) -> ctx.put(e.key, e.value)); assertEquals(0, seen.size()); @@ -149,7 +279,8 @@ void forEachWithContextOnEmptyTableDoesNothing() { @Test void nullKeyIsPermittedAndDistinctFromAbsent() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); assertNull(table.get(null)); StringIntEntry nullKeyed = new StringIntEntry(null, 7); table.insert(nullKeyed); @@ -163,7 +294,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.createBounded(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKeyEntry e1 = new CollidingKeyEntry(k1, 100); @@ -177,7 +309,8 @@ void hashCollisionsResolveByEquality() { @Test void hashCollisionsThenRemoveLeavesOtherIntact() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createBounded(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -193,10 +326,11 @@ void hashCollisionsThenRemoveLeavesOtherIntact() { @Test void getOrCreateOnMissBuildsEntryViaCreator() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); int[] createCount = {0}; StringIntEntry created = - table.getOrCreate( + table.tryGetOrCreateOrNull( "foo", k -> { createCount[0]++; @@ -212,12 +346,13 @@ void getOrCreateOnMissBuildsEntryViaCreator() { @Test void getOrCreateOnHitSkipsCreator() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); StringIntEntry seeded = new StringIntEntry("foo", 1); table.insert(seeded); int[] createCount = {0}; StringIntEntry got = - table.getOrCreate( + table.tryGetOrCreateOrNull( "foo", k -> { createCount[0]++; @@ -230,12 +365,156 @@ 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.createBounded(StringIntEntry.class, 8); + StringIntEntry created = table.tryGetOrCreateOrNull(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.tryGetOrCreateOrNull(null, k -> new StringIntEntry(k, 999))); + assertEquals(1, table.size()); + } + + @Test + void getOrCreateAsMaybeOnMissBuildsEntryViaCreator() { + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); + Maybe maybe = table.tryGetOrCreate("foo", k -> new StringIntEntry(k, 42)); + assertTrue(maybe.isPresent()); + assertEquals(42, maybe.getOrNull().value); + assertSame(table.get("foo"), maybe.getOrNull()); + } + + @Test + void getOrCreateAsMaybeReturnsAbsentOnceAtCapacityButStillReturnsHits() { + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 2); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + + assertFalse(table.tryGetOrCreate("c", k -> new StringIntEntry(k, 3)).isPresent()); + assertEquals(2, table.size()); + + Maybe hit = table.tryGetOrCreate("a", k -> new StringIntEntry(k, 999)); + assertEquals(1, hit.getOrNull().value, "existing entry is still returned even at capacity"); + } + + @Test + void getOrCreateAsMaybeUpdateAppliesOnlyWhenPresent() { + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 1); + table.insert(new StringIntEntry("a", 1)); + + ObjLongConsumer add = (e, n) -> e.value += n; + table.tryGetOrCreate("a", k -> new StringIntEntry(k, 0)).update(5L, add); + assertEquals(6, table.get("a").value); + + table.tryGetOrCreate("b", k -> new StringIntEntry(k, 0)).update(5L, add); + assertNull(table.get("b"), "refused create at capacity leaves nothing to update"); + } + + @Test + void insertReturnsFalseOnceAtCapacity() { + Hashtable.D1 table = + Hashtable.D1.createBounded(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.createBounded(StringIntEntry.class, 2); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + + assertNull(table.tryGetOrCreateOrNull("c", k -> new StringIntEntry(k, 3))); + assertEquals(2, table.size()); + + StringIntEntry hit = table.tryGetOrCreateOrNull("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.createBounded(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.createBounded(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.createBounded(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.createBounded(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.createBounded(StringIntEntry.class, 8); + Map drained = new HashMap<>(); + table.drain(e -> drained.put(e.key, e.value)); + assertEquals(0, drained.size()); + assertEquals(0, table.size()); } } 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..2766cffe015 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java @@ -5,6 +5,7 @@ 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.HashSet; @@ -15,7 +16,7 @@ class HashtableD2Test { @Test void pairKeysParticipateInIdentity() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createBounded(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 +32,7 @@ void pairKeysParticipateInIdentity() { @Test void removePairUnlinks() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); PairEntry ab = new PairEntry("a", 1, 100); PairEntry ac = new PairEntry("a", 2, 200); table.insert(ab); @@ -43,21 +44,137 @@ void removePairUnlinks() { } @Test - void insertOrReplaceMatchesOnBothKeys() { - Hashtable.D2 table = new Hashtable.D2<>(8); + void removeIfUnlinksMatchingPairsAndDecrementsSize() { + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); + PairEntry ab = new PairEntry("a", 1, 100); + PairEntry ac = new PairEntry("a", 2, 200); + PairEntry bb = new PairEntry("b", 1, 300); + table.insert(ab); + table.insert(ac); + table.insert(bb); + + assertTrue(table.removeIf(e -> e.key1().equals("a"))); + + assertEquals(1, table.size()); + assertNull(table.get("a", 1)); + assertNull(table.get("a", 2)); + assertSame(bb, table.get("b", 1)); + } + + @Test + void removeIfReturnsFalseAndLeavesTableUntouchedWhenNothingMatches() { + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); + PairEntry ab = new PairEntry("a", 1, 100); + table.insert(ab); + + assertFalse(table.removeIf(e -> false)); + + assertEquals(1, table.size()); + assertSame(ab, table.get("a", 1)); + } + + @Test + void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() { + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); + + Maybe created = + table.tryGetOrCreateOrEvict("a", 1, (k1, k2) -> new PairEntry(k1, k2, 100), e -> true); + + assertTrue(created.isPresent()); + assertEquals(1, table.size()); + assertSame(created.getOrNull(), table.get("a", 1)); + } + + @Test + void tryGetOrCreateOrEvictReturnsExistingEntryOnHitWithoutEvicting() { + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 1); + PairEntry a = table.tryGetOrCreateOrNull("a", 1, (k1, k2) -> new PairEntry(k1, k2, 100)); + + Maybe got = + table.tryGetOrCreateOrEvict( + "a", + 1, + (k1, k2) -> { + throw new AssertionError("creator must not run on a hit"); + }, + e -> { + throw new AssertionError("evictable must not run on a hit"); + }); + + assertSame(a, got.getOrNull()); + assertEquals(1, table.size()); + } + + @Test + void tryGetOrCreateOrEvictEvictsWhenFullAndInsertsNewEntry() { + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 1); + table.tryGetOrCreateOrNull("old", 1, (k1, k2) -> new PairEntry(k1, k2, 100)); + assertTrue(table.isFull()); + + Maybe created = + table.tryGetOrCreateOrEvict("new", 2, (k1, k2) -> new PairEntry(k1, k2, 200), e -> true); + + assertTrue(created.isPresent()); + assertEquals(1, table.size()); + assertNull(table.get("old", 1)); + assertSame(created.getOrNull(), table.get("new", 2)); + } + + @Test + void tryGetOrCreateOrEvictOrNullRefusesWhenFullAndNothingEvictable() { + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 1); + table.tryGetOrCreateOrNull("old", 1, (k1, k2) -> new PairEntry(k1, k2, 100)); + + PairEntry result = + table.tryGetOrCreateOrEvictOrNull( + "new", 2, (k1, k2) -> new PairEntry(k1, k2, 200), e -> false); + + assertNull(result); + assertEquals(1, table.size()); + assertNotNull(table.get("old", 1)); + assertNull(table.get("new", 2)); + } + + @Test + void tryGetOrCreateOrEvictOrNullEvictionRunsBeforeThrowingCreator() { + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 1); + table.tryGetOrCreateOrNull("old", 1, (k1, k2) -> new PairEntry(k1, k2, 100)); + + assertThrows( + RuntimeException.class, + () -> + table.tryGetOrCreateOrEvictOrNull( + "new", + 2, + (k1, k2) -> { + throw new RuntimeException("boom"); + }, + e -> true)); + + // Eviction already happened before the creator threw: the table is left one entry smaller, + // not corrupted or double-booked. + assertEquals(0, table.size()); + assertNull(table.get("old", 1)); + assertNull(table.get("new", 2)); + } + + @Test + void tryInsertOrReplaceMatchesOnBothKeys() { + Hashtable.D2 table = Hashtable.D2.createBounded(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.createBounded(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); Set seen = new HashSet<>(); @@ -69,7 +186,7 @@ void forEachVisitsBothPairs() { @Test void forEachWithContextPassesContextToConsumer() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); Set seen = new HashSet<>(); @@ -81,10 +198,10 @@ void forEachWithContextPassesContextToConsumer() { @Test void getOrCreateOnMissBuildsEntryViaCreator() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); int[] createCount = {0}; PairEntry created = - table.getOrCreate( + table.tryGetOrCreateOrNull( "a", 1, (k1, k2) -> { @@ -102,12 +219,12 @@ void getOrCreateOnMissBuildsEntryViaCreator() { @Test void getOrCreateOnHitSkipsCreator() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); PairEntry seeded = new PairEntry("a", 1, 100); table.insert(seeded); int[] createCount = {0}; PairEntry got = - table.getOrCreate( + table.tryGetOrCreateOrNull( "a", 1, (k1, k2) -> { @@ -160,7 +277,7 @@ void entryHashDiffersForDifferentKeys() { @Test void removeReturnsNullForMissingKey() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 100)); assertNull(table.remove("a", 2)); @@ -170,7 +287,7 @@ void removeReturnsNullForMissingKey() { @Test void clearEmptiesTable() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); assertEquals(2, table.size()); @@ -182,6 +299,112 @@ void clearEmptiesTable() { assertNull(table.get("b", 2)); } + @Test + void insertReturnsFalseOnceAtCapacity() { + Hashtable.D2 table = Hashtable.D2.createBounded(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.createBounded(PairEntry.class, 2); + table.insert(new PairEntry("a", 1, 100)); + table.insert(new PairEntry("b", 2, 200)); + + assertNull(table.tryGetOrCreateOrNull("c", 3, (k1, k2) -> new PairEntry(k1, k2, 300))); + assertEquals(2, table.size()); + + PairEntry hit = table.tryGetOrCreateOrNull("a", 1, (k1, k2) -> new PairEntry(k1, k2, 999)); + assertEquals(100, hit.value, "existing entry is still returned even at capacity"); + } + + @Test + void getOrCreateAsMaybeReturnsAbsentOnceAtCapacityButStillReturnsHits() { + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 2); + table.insert(new PairEntry("a", 1, 100)); + table.insert(new PairEntry("b", 2, 200)); + + assertFalse(table.tryGetOrCreate("c", 3, (k1, k2) -> new PairEntry(k1, k2, 300)).isPresent()); + assertEquals(2, table.size()); + + Maybe hit = table.tryGetOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 999)); + assertEquals(100, hit.getOrNull().value, "existing entry is still returned even at capacity"); + } + + @Test + void tryInsertOrReplaceStillReplacesAtCapacityButRefusesFreshInsert() { + Hashtable.D2 table = Hashtable.D2.createBounded(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.createBounded(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.createBounded(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.createBounded(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.createBounded(PairEntry.class, 8); + Set drained = new HashSet<>(); + table.drain(e -> drained.add(e.key1 + ":" + e.key2)); + assertEquals(0, drained.size()); + assertEquals(0, 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..fcf1314b7ec 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -3,6 +3,7 @@ import static datadog.trace.util.HashtableTestEntries.CollidingKey; import static datadog.trace.util.HashtableTestEntries.CollidingKeyEntry; import static datadog.trace.util.HashtableTestEntries.StringIntEntry; +import static java.util.Arrays.asList; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -10,29 +11,33 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import datadog.trace.util.Hashtable.BucketIterator; 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 java.util.function.Predicate; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; 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 +46,201 @@ 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 -> ceil to 7 -> sizeFor rounds up to 8. + assertEquals(8, Hashtable.capacityFor(5)); + } + + @Test + void capacityForKeepsHeadroomForSmallCardinalityLimits() { + // 1 / 0.75 = 1.33 -> ceil to 2 -> sizeFor rounds up to 2. Truncating instead of ceiling + // would give sizeFor(1) = 1, a 1.0 load factor rather than the documented 0.75. + assertEquals(2, Hashtable.capacityFor(1)); + // 2 / 0.75 = 2.67 -> ceil to 3 -> sizeFor rounds up to 4. Truncating instead would give + // sizeFor(2) = 2, a 1.0 load factor rather than the documented 0.75. + assertEquals(4, Hashtable.capacityFor(2)); + } + + @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.createBounded(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.createBounded(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.createBounded(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()); + } + + @Test + void insertHeadEntryOfAlreadyLinkedEntryTripsAssertion() { + assumeTrue(assertionsEnabled(), "assert-guard test requires -ea"); + Hashtable.State table = Hashtable.createBounded(4); + Hashtable.Entry[] buckets = table.buckets; + StringIntEntry a = new StringIntEntry("a", 1); + StringIntEntry b = new StringIntEntry("b", 2); + Hashtable.insertHeadEntryAt(buckets, 0, a); + Hashtable.insertHeadEntryAt(buckets, 0, b); // chain is now b -> a, so b.next() != null + assertThrows( + AssertionError.class, + () -> Hashtable.insertHeadEntryAt(buckets, 1, b), + "re-inserting an already-linked entry corrupts the chain and must be caught"); + } + } + + // ============ 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 +258,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 +375,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.createBounded(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -136,7 +386,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 +397,11 @@ void walksOnlyMatchingHash() { @Test void exhaustedIteratorThrowsNoSuchElement() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createBounded(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 +416,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.createBounded(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -174,7 +426,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 +450,8 @@ void removeFromHeadOfChainUnlinks() { @Test void replaceSwapsEntryAndPreservesChain() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createBounded(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKeyEntry e1 = new CollidingKeyEntry(k1, 1); @@ -207,7 +460,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 +473,11 @@ void replaceSwapsEntryAndPreservesChain() { @Test void removeWithoutNextThrows() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createBounded(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 +489,15 @@ class MutatingTableIteratorTests { @Test void walksEveryEntryAcrossBuckets() { - Hashtable.D1 table = new Hashtable.D1<>(16); + Hashtable.D1 table = + Hashtable.D1.createBounded(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 +509,24 @@ void walksEveryEntryAcrossBuckets() { @Test void emptyTableIteratorIsExhausted() { - Hashtable.D1 table = new Hashtable.D1<>(8); - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + Hashtable.D1 table = + Hashtable.D1.createBounded(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.createBounded(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 +538,8 @@ void removeUnlinksBucketHead() { @Test void removeUnlinksMidChainEntry() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createBounded(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -289,7 +548,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 +574,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.createBounded(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 +593,20 @@ void removeSkipsOverEmptyBuckets() { @Test void removeWithoutNextThrows() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createBounded(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.createBounded(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 +617,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.createBounded(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 +637,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.createBounded(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.createBounded(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 +666,366 @@ 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.createBounded(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.createBounded(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.createBounded(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.createBounded(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.createBounded(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.createBounded(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.createBounded(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.createBounded(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.createBounded(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.createBounded(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.createBounded(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 drainKeepsCountConsistentWhenTheSinkThrows() { + Hashtable.State table = Hashtable.createBounded(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)); + + // Drains some entries, then blows up. The count must reflect what actually left the table. + assertThrows( + IllegalStateException.class, + () -> + Hashtable.drain( + table, + e -> { + if (e.value == 3) { + throw new IllegalStateException("boom"); + } + })); + + 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 drain"); + } + + @Test + void evictOneAdvancesCursorEvenWhenNothingMatches() { + Hashtable.State table = Hashtable.createBounded(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.createBounded(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 evictOneResumesMidChainInsteadOfRescanningTheNonEvictablePrefix() { + Hashtable.State table = Hashtable.createBounded(8); + int hash = 17; + CollidingKeyEntry sticky1 = new CollidingKeyEntry(new CollidingKey("sticky1", hash), -1); + CollidingKeyEntry sticky2 = new CollidingKeyEntry(new CollidingKey("sticky2", hash), -1); + CollidingKeyEntry sticky3 = new CollidingKeyEntry(new CollidingKey("sticky3", hash), -1); + CollidingKeyEntry e1 = new CollidingKeyEntry(new CollidingKey("e1", hash), 1); + CollidingKeyEntry e2 = new CollidingKeyEntry(new CollidingKey("e2", hash), 2); + CollidingKeyEntry e3 = new CollidingKeyEntry(new CollidingKey("e3", hash), 3); + CollidingKeyEntry e4 = new CollidingKeyEntry(new CollidingKey("e4", hash), 4); + CollidingKeyEntry e5 = new CollidingKeyEntry(new CollidingKey("e5", hash), 5); + + // insertHeadEntryFor prepends, so inserting in this order chains them head-to-tail as + // sticky1, sticky2, sticky3, e1, e2, e3, e4, e5 -- a non-evictable prefix followed by a run + // of evictable entries, the shape that made evictOne's prefix rescan quadratic. + for (CollidingKeyEntry e : + new CollidingKeyEntry[] {e5, e4, e3, e2, e1, sticky3, sticky2, sticky1}) { + assertTrue(Hashtable.insertHeadEntryFor(table, e.keyHash, e)); + } + + int[] predicateCalls = {0}; + Predicate evictable = + e -> { + predicateCalls[0]++; + return e.value >= 0; + }; + + List evictedInOrder = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + CollidingKeyEntry evicted = Hashtable.evictOne(table, evictable); + evictedInOrder.add(evicted.value); + } + + assertEquals(asList(1, 2, 3, 4, 5), evictedInOrder); + // Without mid-chain resume, each eviction would re-test the 3-entry sticky prefix: 5 * 4 = + // 20 calls. Resuming right after the last removal costs 4 (the first pass, prefix + e1) plus + // 1 per subsequent eviction (straight to the next entry) = 8. + assertEquals( + 8, predicateCalls[0], "must resume after the last eviction, not rescan the prefix"); + assertEquals(3, Hashtable.estimateSize(table), "only the sticky prefix remains"); + } + + @Test + void drainRemovesAllMatchesAndResetsCursor() { + Hashtable.State table = Hashtable.createBounded(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.createBounded(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.createBounded(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.createBounded(1); + + assertTrue(table.sizeManager.tryReserve()); + assertTrue(table.sizeManager.isFull()); + assertFalse(table.sizeManager.tryReserve()); + } + + @Test + void tableSizeManagerOperatesOnItsOwnBuckets() { + Hashtable.State table = Hashtable.createBounded(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]); + } + } + + private static boolean assertionsEnabled() { + boolean enabled = false; + assert enabled = true; + return enabled; + } }