From f932ad824b3d83a09809db9f6e0df1d10d7d1339 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 11:08:20 -0400 Subject: [PATCH 01/41] Unify Hashtable static API with ConcurrentHashtable; deprecate Support Move the static building blocks off the nested Support class onto Hashtable itself, mirroring ConcurrentHashtable's flat layout, and add createFixedBuckets(Class, int) factories on Hashtable/D1/D2 for family symmetry. Support becomes a thin @Deprecated facade delegating to the new statics (retaining the scaled create(int, float)/MAX_RATIO helpers, which have no blessed equivalent), so client-side-statistics callers keep compiling untouched. Rename the context type parameter -> on the context-passing forEach overloads, and add D2.Entry.key1()/key2() accessors to match D1/the concurrent variant. No behavior change; pure API relocation + deprecation. Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/util/Hashtable.java | 482 ++++++++++++------ 1 file changed, 329 insertions(+), 153 deletions(-) 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..286d401d017 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -23,10 +23,14 @@ * 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. * *

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 #createFixedBuckets(Class, int)}, {@link + * #bucket(Hashtable.Entry[], long)}, {@link #insertHeadEntry(Hashtable.Entry[], int, + * Hashtable.Entry)}, and friends). The deprecated {@link Support} class is a thin facade over those + * same statics, retained for source compatibility. */ public final class Hashtable { private Hashtable() {} @@ -37,7 +41,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; @@ -121,25 +126,40 @@ public static long hash(Object key) { } } - // 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; public D1(int capacity) { - this.buckets = Support.create(capacity); + this.buckets = new Hashtable.Entry[sizeFor(capacity)]; this.size = 0; } + /** + * Creates a single-key table with a fixed bucket count sized for {@code capacity} entries. The + * {@code entryClass} pins the concrete entry type so the compiler infers both {@code K} and + * {@code TEntry} at the call site -- e.g. {@code D1.createFixedBuckets(MyEntry.class, 64)} -- + * keeping the factory symmetric with the rest of the flat-collections family (see {@link + * Hashtable#createFixedBuckets(Class, int)} for why the class isn't otherwise consumed). + * Capacity is fixed; the table does not resize. + */ + public static > D1 createFixedBuckets( + Class entryClass, int capacity) { + return new D1<>(capacity); + } + public int size() { return this.size; } public TEntry get(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 = bucket(this.buckets, keyHash); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return curEntry; } } return null; @@ -148,8 +168,7 @@ public TEntry get(K key) { public TEntry remove(K key) { 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(); @@ -164,13 +183,13 @@ public TEntry remove(K key) { } public void insert(TEntry newEntry) { - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); this.size += 1; } public TEntry insertOrReplace(TEntry newEntry) { for (MutatingBucketIterator iter = - Support.mutatingBucketIterator(this.buckets, newEntry.keyHash); + mutatingBucketIterator(this.buckets, newEntry.keyHash); iter.hasNext(); ) { TEntry curEntry = iter.next(); @@ -180,7 +199,7 @@ public TEntry insertOrReplace(TEntry newEntry) { } } - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); this.size += 1; return null; } @@ -197,24 +216,26 @@ public TEntry insertOrReplace(TEntry newEntry) { */ public TEntry getOrCreate(K key, 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 = bucket(this.buckets, keyHash); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return curEntry; } } TEntry newEntry = creator.apply(key); - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); this.size += 1; return newEntry; } public void clear() { - Support.clear(this.buckets); + Hashtable.clear(this.buckets); this.size = 0; } public void forEach(Consumer consumer) { - Support.forEach(this.buckets, consumer); + Hashtable.forEach(this.buckets, consumer); } /** @@ -222,8 +243,8 @@ 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, BiConsumer consumer) { + Hashtable.forEach(this.buckets, context, consumer); } } @@ -301,19 +322,34 @@ public static long hash(Object key1, Object key2) { private int size; public D2(int capacity) { - this.buckets = Support.create(capacity); + this.buckets = new Hashtable.Entry[sizeFor(capacity)]; this.size = 0; } + /** + * Creates a composite-key table with a fixed bucket count sized for {@code capacity} entries. + * The {@code entryClass} pins the concrete entry type so the compiler infers {@code K1}, {@code + * K2}, and {@code TEntry} at the call site -- e.g. {@code D2.createFixedBuckets(MyEntry.class, + * 64)} -- keeping the factory symmetric with the rest of the flat-collections family (see + * {@link Hashtable#createFixedBuckets(Class, int)} for why the class isn't otherwise consumed). + * Capacity is fixed; the table does not resize. + */ + public static > D2 createFixedBuckets( + Class entryClass, int capacity) { + return new D2<>(capacity); + } + public int size() { return this.size; } public TEntry get(K1 key1, 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 = bucket(this.buckets, keyHash); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return curEntry; } } return null; @@ -322,8 +358,7 @@ public TEntry get(K1 key1, K2 key2) { public TEntry remove(K1 key1, K2 key2) { 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(); @@ -338,13 +373,13 @@ public TEntry remove(K1 key1, K2 key2) { } public void insert(TEntry newEntry) { - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); this.size += 1; } public TEntry insertOrReplace(TEntry newEntry) { for (MutatingBucketIterator iter = - Support.mutatingBucketIterator(this.buckets, newEntry.keyHash); + mutatingBucketIterator(this.buckets, newEntry.keyHash); iter.hasNext(); ) { TEntry curEntry = iter.next(); @@ -354,7 +389,7 @@ public TEntry insertOrReplace(TEntry newEntry) { } } - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); this.size += 1; return null; } @@ -367,24 +402,26 @@ public TEntry insertOrReplace(TEntry newEntry) { public TEntry getOrCreate( K1 key1, K2 key2, 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 = bucket(this.buckets, keyHash); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return curEntry; } } TEntry newEntry = creator.apply(key1, key2); - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); this.size += 1; return newEntry; } public void clear() { - Support.clear(this.buckets); + Hashtable.clear(this.buckets); this.size = 0; } public void forEach(Consumer consumer) { - Support.forEach(this.buckets, consumer); + Hashtable.forEach(this.buckets, consumer); } /** @@ -392,195 +429,334 @@ 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, BiConsumer consumer) { + Hashtable.forEach(this.buckets, context, consumer); + } + } + + // ============================================================================================ + // 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. This is the same "static functions over a + // caller-owned array" shape as the concurrent variant (ConcurrentHashtable); see how + // AggregateTable drives a Hashtable.Entry[] with these. 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. + // + // These were previously nested under the Support class; that class is now a deprecated facade + // delegating here (retained for source compatibility with existing callers such as client-side + // statistics). + // ============================================================================================ + + /** Upper bound on the bucket-array length returned by {@link #sizeFor(int)}. */ + static final int MAX_BUCKETS = 1 << 30; + + /** + * Allocates a fixed-size bucket array sized to hold {@code capacity} entries: {@code capacity} + * rounded up to the next power of two. + * + *

Returns a concrete {@code Hashtable.Entry[]} (chain heads are stored at the base type), so + * the array assigns directly to a caller's {@code Hashtable.Entry[]} field. As with the + * concurrent variant's {@code createFixedBuckets}, {@code entryClass} is not consumed to + * allocate -- the array is a heterogeneous {@code Entry[]}, not a reflectively-allocated {@code + * TEntry[]}. It is accepted only to keep the factory call-shape symmetric across the + * flat-collections family ({@code createFixedBuckets(MyEntry.class, n)}). Capacity is fixed; the + * table does not resize. + * + *

For load-factor headroom over a target working-set size, size {@code capacity} yourself + * (e.g. {@code createFixedBuckets(MyEntry.class, (int) (n * 4 / 3f))}); the deprecated {@link + * Support#create(int, float)} bundled that scaling but has no blessed equivalent. + */ + public static Hashtable.Entry[] createFixedBuckets( + Class entryClass, int capacity) { + return new Entry[sizeFor(capacity)]; + } + + /** + * 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. The concurrent variant shares this so the two families + * round identically. + */ + 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(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. + */ + @SuppressWarnings("unchecked") + public static TEntry bucket(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 insertHeadEntry( + Hashtable.Entry[] buckets, int bucketIndex, Hashtable.Entry entry) { + entry.setNext(buckets[bucketIndex]); + 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. + */ + public static void insertHeadEntry( + Hashtable.Entry[] buckets, long keyHash, Hashtable.Entry entry) { + insertHeadEntry(buckets, bucketIndex(buckets, keyHash), entry); + } + + public static void clear(Hashtable.Entry[] buckets) { + Arrays.fill(buckets, null); + } + + /** + * 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( + 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); + } } } /** - * Building blocks for hash-table operations. + * 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( + Hashtable.Entry[] buckets, C 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); + } + } + } + + public static BucketIterator bucketIterator( + Hashtable.Entry[] buckets, long keyHash) { + return new BucketIterator(buckets, keyHash); + } + + public static + MutatingBucketIterator mutatingBucketIterator( + 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. + */ + public static + MutatingTableIterator mutatingTableIterator(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. 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. * - *

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: + * @param startBucket inclusive lower bound; must be in {@code [0, buckets.length]}. + * @param endBucket exclusive upper bound; must be in {@code [startBucket, buckets.length]}. + */ + public static + MutatingTableIterator mutatingTableIterator( + Hashtable.Entry[] buckets, int startBucket, int endBucket) { + return new MutatingTableIterator(buckets, startBucket, endBucket); + } + + /** + * Deprecated facade over the static building blocks that are now methods on {@link Hashtable} + * itself (mirroring the concurrent variant). Each method here delegates to its {@code + * Hashtable.*} counterpart; the two sizing helpers with no blessed equivalent -- {@link + * #create(int, float)} and {@link #MAX_RATIO} -- keep their real bodies here. * - *

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

Retained only for source compatibility with existing callers (e.g. client-side statistics). + * New code should call the {@code Hashtable.*} statics directly. * - *

All bucket arrays produced by {@code create} have a power-of-two length, so {@link - * #bucketIndex(Object[], long)} can use a bit mask. + * @deprecated use the static building blocks on {@link Hashtable} directly. */ + @Deprecated public static final class Support { + private Support() {} + /** - * 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}). + * @deprecated use {@link Hashtable#createFixedBuckets(Class, int)}. */ - public static final Hashtable.Entry[] create(int requestedSize) { + @Deprecated + public static Hashtable.Entry[] create(int requestedSize) { return new Entry[sizeFor(requestedSize)]; } /** - * 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)}. + * Scales the requested working-set size before sizing the bucket array. Pair with {@link + * #MAX_RATIO} to leave headroom over the working set for a desired load factor; the canonical + * call is {@code create(n, MAX_RATIO)}. + * + *

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

No blessed equivalent: callers wanting load-factor headroom size the capacity themselves + * and call {@link Hashtable#createFixedBuckets(Class, int)}. * - *

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}). + * @deprecated size the capacity yourself and use {@link Hashtable#createFixedBuckets(Class, + * int)}. */ - public static final Hashtable.Entry[] create(int requestedSize, float scale) { + @Deprecated + public static Hashtable.Entry[] create(int requestedSize, float scale) { return new Entry[sizeFor((int) (requestedSize * scale))]; } - /** Upper bound on the bucket array length returned by {@link #sizeFor(int)}. */ - static final int MAX_BUCKETS = 1 << 30; - /** * 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. */ - public static final float MAX_RATIO = 4.0f / 3.0f; + @Deprecated public static final float MAX_RATIO = 4.0f / 3.0f; /** - * 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. + * @deprecated use {@link Hashtable#sizeFor(int)}. */ - static final 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; + @Deprecated + static int sizeFor(int requestedSize) { + return Hashtable.sizeFor(requestedSize); } - public static final void clear(Hashtable.Entry[] buckets) { - Arrays.fill(buckets, null); + /** + * @deprecated use {@link Hashtable#clear(Hashtable.Entry[])}. + */ + @Deprecated + public static void clear(Hashtable.Entry[] buckets) { + Hashtable.clear(buckets); } - public static final BucketIterator bucketIterator( + /** + * @deprecated use {@link Hashtable#bucketIterator(Hashtable.Entry[], long)}. + */ + @Deprecated + public static BucketIterator bucketIterator( Hashtable.Entry[] buckets, long keyHash) { - return new BucketIterator(buckets, keyHash); + return Hashtable.bucketIterator(buckets, keyHash); } - public static final + /** + * @deprecated use {@link Hashtable#mutatingBucketIterator(Hashtable.Entry[], long)}. + */ + @Deprecated + public static MutatingBucketIterator mutatingBucketIterator( Hashtable.Entry[] buckets, long keyHash) { - return new MutatingBucketIterator(buckets, keyHash); + return Hashtable.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. + * @deprecated use {@link Hashtable#mutatingTableIterator(Hashtable.Entry[])}. */ - public static final + @Deprecated + public static MutatingTableIterator mutatingTableIterator(Hashtable.Entry[] buckets) { - return new MutatingTableIterator(buckets, 0, buckets.length); + return Hashtable.mutatingTableIterator(buckets); } /** - * 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. - * - * @param startBucket inclusive lower bound; must be in {@code [0, buckets.length]}. - * @param endBucket exclusive upper bound; must be in {@code [startBucket, buckets.length]}. + * @deprecated use {@link Hashtable#mutatingTableIterator(Hashtable.Entry[], int, int)}. */ - public static final + @Deprecated + public static MutatingTableIterator mutatingTableIterator( Hashtable.Entry[] buckets, int startBucket, int endBucket) { - return new MutatingTableIterator(buckets, startBucket, 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(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#insertHeadEntry(Hashtable.Entry[], int, Hashtable.Entry)}. */ - public static final void insertHeadEntry( + @Deprecated + public static void insertHeadEntry( Hashtable.Entry[] buckets, int bucketIndex, Hashtable.Entry entry) { - entry.setNext(buckets[bucketIndex]); - buckets[bucketIndex] = entry; + Hashtable.insertHeadEntry(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#insertHeadEntry(Hashtable.Entry[], long, Hashtable.Entry)}. */ - public static final void insertHeadEntry( + @Deprecated + public static void insertHeadEntry( Hashtable.Entry[] buckets, long keyHash, Hashtable.Entry entry) { - insertHeadEntry(buckets, bucketIndex(buckets, keyHash), entry); + Hashtable.insertHeadEntry(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#bucket(Hashtable.Entry[], long)}. */ - @SuppressWarnings("unchecked") - public static final TEntry bucket( + @Deprecated + public static TEntry bucket( Hashtable.Entry[] buckets, long keyHash) { - return (TEntry) buckets[bucketIndex(buckets, keyHash)]; + return Hashtable.bucket(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( + @Deprecated + public static 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); - } - } + 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( + Hashtable.Entry[] buckets, C context, BiConsumer consumer) { + Hashtable.forEach(buckets, context, consumer); } } From 5367290e9323074220122018efa9450c478623e2 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 11:13:30 -0400 Subject: [PATCH 02/41] Migrate HashtableTest to blessed Hashtable static API Point the tests at the relocated static building blocks on Hashtable (createFixedBuckets, sizeFor, bucketIndex, clear, insertHeadEntry, and the iterator factories) instead of the now-deprecated Support facade. Keep a small DeprecatedSupportTests group covering the deprecated-only scaled create(int, float) + MAX_RATIO, which have no blessed equivalent and remain in use by client-side statistics. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/HashtableTest.java | 118 ++++++++++-------- 1 file changed, 66 insertions(+), 52 deletions(-) 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..f566d04ee0d 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -23,16 +23,16 @@ class HashtableTest { - // ============ Support ============ + // ============ Static building blocks ============ @Nested - class SupportTests { + class StaticBuildingBlockTests { @Test void createRoundsCapacityUpToPowerOfTwo() { // The Hashtable.D1 / D2 size() reflects entries, but the bucket array length is // a power of two >= requestedCapacity. We can verify indirectly via bucketIndex masking. - Hashtable.Entry[] buckets = Support.create(5); + Hashtable.Entry[] buckets = Hashtable.createFixedBuckets(StringIntEntry.class, 5); // Length must be a power of two >= 5 int len = buckets.length; assertTrue(len >= 5); @@ -41,51 +41,78 @@ 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 bucketIndexIsBoundedByArrayLength() { - Hashtable.Entry[] buckets = Support.create(16); + Hashtable.Entry[] buckets = Hashtable.createFixedBuckets(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.Entry[] buckets = Hashtable.createFixedBuckets(StringIntEntry.class, 4); 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 insertHeadEntrySplicesAsNewHead() { + Hashtable.Entry[] buckets = Hashtable.createFixedBuckets(StringIntEntry.class, 4); + StringIntEntry a = new StringIntEntry("a", 1); + StringIntEntry b = new StringIntEntry("b", 2); + Hashtable.insertHeadEntry(buckets, 0, a); + assertSame(a, buckets[0]); + assertNull(a.next()); + + Hashtable.insertHeadEntry(buckets, 0, b); + assertSame(b, buckets[0]); + assertSame(a, b.next()); + assertNull(a.next()); + } + } + + // ============ Deprecated Support facade ============ + + /** + * The scaled {@code create(int, float)} factory and {@code MAX_RATIO} are deprecated-only: they + * have no blessed equivalent on {@link Hashtable} but remain in use by client-side statistics, so + * they keep dedicated coverage here. + */ + @Nested + @SuppressWarnings("deprecation") + class DeprecatedSupportTests { + @Test void maxRatioScalesTargetForLoadFactor() { // 75% load factor => bucket array sized at requestedSize * 4/3, rounded up to power of 2. @@ -101,21 +128,6 @@ void createWithScaleRoundsUpToPowerOfTwo() { Hashtable.Entry[] buckets = Support.create(7, 1.5f); assertEquals(16, buckets.length); } - - @Test - void insertHeadEntrySplicesAsNewHead() { - 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()); - - Support.insertHeadEntry(buckets, 0, b); - assertSame(b, buckets[0]); - assertSame(a, b.next()); - assertNull(a.next()); - } } // ============ BucketIterator ============ @@ -126,7 +138,7 @@ 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); CollidingKey k1 = new CollidingKey("first", 17); @@ -136,7 +148,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()); @@ -150,7 +162,7 @@ void exhaustedIteratorThrowsNoSuchElement() { Hashtable.D1 table = new Hashtable.D1<>(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); @@ -174,7 +186,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 @@ -207,7 +219,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); @@ -223,7 +235,7 @@ void removeWithoutNextThrows() { Hashtable.D1 table = new Hashtable.D1<>(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); } } @@ -241,7 +253,8 @@ void walksEveryEntryAcrossBuckets() { 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); } @@ -254,7 +267,7 @@ void walksEveryEntryAcrossBuckets() { @Test void emptyTableIteratorIsExhausted() { Hashtable.D1 table = new Hashtable.D1<>(8); - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); assertFalse(it.hasNext()); assertThrows(NoSuchElementException.class, it::next); } @@ -268,7 +281,7 @@ void removeUnlinksBucketHead() { 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(); @@ -289,7 +302,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(); @@ -320,7 +333,7 @@ void removeSkipsOverEmptyBuckets() { 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; @@ -335,7 +348,7 @@ void removeSkipsOverEmptyBuckets() { void removeWithoutNextThrows() { Hashtable.D1 table = new Hashtable.D1<>(4); table.insert(new StringIntEntry("a", 1)); - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); assertThrows(IllegalStateException.class, it::remove); } @@ -344,7 +357,7 @@ void removeTwiceWithoutInterveningNextThrows() { Hashtable.D1 table = new Hashtable.D1<>(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); @@ -362,7 +375,7 @@ void halfOpenRangeOmitsBucketsOutsideTheRange() { 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); } @@ -376,7 +389,8 @@ void emptyHalfOpenRangeIsExhausted() { // pass [0, cursor) when cursor == 0 in resumable sweeps. Hashtable.D1 table = new Hashtable.D1<>(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()); } @@ -385,14 +399,14 @@ void rangeBoundsOutOfOrderThrows() { Hashtable.D1 table = new Hashtable.D1<>(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 } @@ -403,7 +417,7 @@ void currentBucketReportsLandingIndex() { Hashtable.D1 table = new Hashtable.D1<>(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"); From f0a72abea2bd9e56bd2125a7f2f4c3af2b189d90 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 12:41:50 -0400 Subject: [PATCH 03/41] Hashtable: annotate nullability (@Nonnull/@Nullable) Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/util/Hashtable.java | 142 +++++++++++------- 1 file changed, 91 insertions(+), 51 deletions(-) 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 286d401d017..32351cbf208 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -8,6 +8,8 @@ import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; /** * Light weight simple Hashtable system that can be useful when HashMap would be unnecessarily @@ -52,11 +54,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; } @@ -99,17 +102,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); } @@ -121,7 +125,7 @@ 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(); } } @@ -144,8 +148,9 @@ public D1(int capacity) { * Hashtable#createFixedBuckets(Class, int)} for why the class isn't otherwise consumed). * Capacity is fixed; the table does not resize. */ + @Nonnull public static > D1 createFixedBuckets( - Class entryClass, int capacity) { + @Nonnull Class entryClass, int capacity) { return new D1<>(capacity); } @@ -153,7 +158,8 @@ public int size() { return this.size; } - public TEntry get(K key) { + @Nullable + public TEntry get(@Nullable K key) { long keyHash = D1.Entry.hash(key); for (TEntry curEntry = bucket(this.buckets, keyHash); curEntry != null; @@ -165,7 +171,8 @@ public TEntry get(K key) { return null; } - public TEntry remove(K key) { + @Nullable + public TEntry remove(@Nullable K key) { long keyHash = D1.Entry.hash(key); for (MutatingBucketIterator iter = mutatingBucketIterator(this.buckets, keyHash); @@ -182,12 +189,13 @@ public TEntry remove(K key) { return null; } - public void insert(TEntry newEntry) { + public void insert(@Nonnull TEntry newEntry) { insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); this.size += 1; } - public TEntry insertOrReplace(TEntry newEntry) { + @Nullable + public TEntry insertOrReplace(@Nonnull TEntry newEntry) { for (MutatingBucketIterator iter = mutatingBucketIterator(this.buckets, newEntry.keyHash); iter.hasNext(); ) { @@ -214,7 +222,9 @@ public TEntry insertOrReplace(TEntry newEntry) { * that calls {@code super(key)}. A mismatched hash will leave the new entry inserted at a * bucket that future {@link #get} calls won't probe. */ - public TEntry getOrCreate(K key, Function creator) { + @Nonnull + public TEntry getOrCreate( + @Nullable K key, @Nonnull Function creator) { long keyHash = D1.Entry.hash(key); for (TEntry curEntry = bucket(this.buckets, keyHash); curEntry != null; @@ -234,7 +244,7 @@ public void clear() { this.size = 0; } - public void forEach(Consumer consumer) { + public void forEach(@Nonnull Consumer consumer) { Hashtable.forEach(this.buckets, consumer); } @@ -243,7 +253,7 @@ 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(C context, BiConsumer consumer) { + public void forEach(C context, @Nonnull BiConsumer consumer) { Hashtable.forEach(this.buckets, context, consumer); } } @@ -285,23 +295,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); } @@ -312,7 +324,7 @@ 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); } } @@ -334,8 +346,9 @@ public D2(int capacity) { * {@link Hashtable#createFixedBuckets(Class, int)} for why the class isn't otherwise consumed). * Capacity is fixed; the table does not resize. */ + @Nonnull public static > D2 createFixedBuckets( - Class entryClass, int capacity) { + @Nonnull Class entryClass, int capacity) { return new D2<>(capacity); } @@ -343,7 +356,8 @@ public int size() { return this.size; } - public TEntry get(K1 key1, K2 key2) { + @Nullable + public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { long keyHash = D2.Entry.hash(key1, key2); for (TEntry curEntry = bucket(this.buckets, keyHash); curEntry != null; @@ -355,7 +369,8 @@ public TEntry get(K1 key1, K2 key2) { return null; } - public TEntry remove(K1 key1, K2 key2) { + @Nullable + public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { long keyHash = D2.Entry.hash(key1, key2); for (MutatingBucketIterator iter = mutatingBucketIterator(this.buckets, keyHash); @@ -372,12 +387,13 @@ public TEntry remove(K1 key1, K2 key2) { return null; } - public void insert(TEntry newEntry) { + public void insert(@Nonnull TEntry newEntry) { insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); this.size += 1; } - public TEntry insertOrReplace(TEntry newEntry) { + @Nullable + public TEntry insertOrReplace(@Nonnull TEntry newEntry) { for (MutatingBucketIterator iter = mutatingBucketIterator(this.buckets, newEntry.keyHash); iter.hasNext(); ) { @@ -399,8 +415,11 @@ public TEntry insertOrReplace(TEntry newEntry) { * 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)}. */ + @Nonnull public TEntry getOrCreate( - K1 key1, K2 key2, BiFunction creator) { + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull BiFunction creator) { long keyHash = D2.Entry.hash(key1, key2); for (TEntry curEntry = bucket(this.buckets, keyHash); curEntry != null; @@ -420,7 +439,7 @@ public void clear() { this.size = 0; } - public void forEach(Consumer consumer) { + public void forEach(@Nonnull Consumer consumer) { Hashtable.forEach(this.buckets, consumer); } @@ -429,7 +448,7 @@ 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(C context, BiConsumer consumer) { + public void forEach(C context, @Nonnull BiConsumer consumer) { Hashtable.forEach(this.buckets, context, consumer); } } @@ -470,8 +489,9 @@ public void forEach(C context, BiConsumer consume * (e.g. {@code createFixedBuckets(MyEntry.class, (int) (n * 4 / 3f))}); the deprecated {@link * Support#create(int, float)} bundled that scaling but has no blessed equivalent. */ + @Nonnull public static Hashtable.Entry[] createFixedBuckets( - Class entryClass, int capacity) { + @Nonnull Class entryClass, int capacity) { return new Entry[sizeFor(capacity)]; } @@ -495,7 +515,7 @@ public static int sizeFor(int requestedSize) { return Integer.highestOneBit(requestedSize - 1) << 1; } - public static int bucketIndex(Object[] buckets, long keyHash) { + public static int bucketIndex(@Nonnull Object[] buckets, long keyHash) { return (int) (keyHash & buckets.length - 1); } @@ -505,7 +525,9 @@ public static int bucketIndex(Object[] buckets, long keyHash) { * doesn't need to thread a raw {@link Entry} variable through. */ @SuppressWarnings("unchecked") - public static TEntry bucket(Hashtable.Entry[] buckets, long keyHash) { + @Nullable + public static TEntry bucket( + @Nonnull Hashtable.Entry[] buckets, long keyHash) { return (TEntry) buckets[bucketIndex(buckets, keyHash)]; } @@ -514,7 +536,7 @@ public static TEntry bucket(Hashtable.Entry[] buckets, lo * responsible for size accounting -- this method only touches the chain pointers. */ public static void insertHeadEntry( - Hashtable.Entry[] buckets, int bucketIndex, Hashtable.Entry entry) { + @Nonnull Hashtable.Entry[] buckets, int bucketIndex, @Nonnull Hashtable.Entry entry) { entry.setNext(buckets[bucketIndex]); buckets[bucketIndex] = entry; } @@ -526,11 +548,11 @@ public static void insertHeadEntry( * overload to avoid the redundant mask. */ public static void insertHeadEntry( - Hashtable.Entry[] buckets, long keyHash, Hashtable.Entry entry) { + @Nonnull Hashtable.Entry[] buckets, long keyHash, @Nonnull Hashtable.Entry entry) { insertHeadEntry(buckets, bucketIndex(buckets, keyHash), entry); } - public static void clear(Hashtable.Entry[] buckets) { + public static void clear(@Nonnull Hashtable.Entry[] buckets) { Arrays.fill(buckets, null); } @@ -541,7 +563,7 @@ public static void clear(Hashtable.Entry[] buckets) { */ @SuppressWarnings("unchecked") public static void forEach( - Hashtable.Entry[] buckets, Consumer consumer) { + @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); @@ -556,7 +578,9 @@ public static void forEach( */ @SuppressWarnings("unchecked") public static void forEach( - Hashtable.Entry[] buckets, C context, BiConsumer consumer) { + @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); @@ -564,14 +588,16 @@ public static void forEach( } } + @Nonnull public static BucketIterator bucketIterator( - Hashtable.Entry[] buckets, long keyHash) { + @Nonnull Hashtable.Entry[] buckets, long keyHash) { return new BucketIterator(buckets, keyHash); } + @Nonnull public static MutatingBucketIterator mutatingBucketIterator( - Hashtable.Entry[] buckets, long keyHash) { + @Nonnull Hashtable.Entry[] buckets, long keyHash) { return new MutatingBucketIterator(buckets, keyHash); } @@ -579,8 +605,9 @@ MutatingBucketIterator mutatingBucketIterator( * 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(Hashtable.Entry[] buckets) { + MutatingTableIterator mutatingTableIterator(@Nonnull Hashtable.Entry[] buckets) { return new MutatingTableIterator(buckets, 0, buckets.length); } @@ -595,9 +622,10 @@ MutatingTableIterator mutatingTableIterator(Hashtable.Entry[] buckets) { * @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( - Hashtable.Entry[] buckets, int startBucket, int endBucket) { + @Nonnull Hashtable.Entry[] buckets, int startBucket, int endBucket) { return new MutatingTableIterator(buckets, startBucket, endBucket); } @@ -620,6 +648,7 @@ private Support() {} * @deprecated use {@link Hashtable#createFixedBuckets(Class, int)}. */ @Deprecated + @Nonnull public static Hashtable.Entry[] create(int requestedSize) { return new Entry[sizeFor(requestedSize)]; } @@ -642,6 +671,7 @@ public static Hashtable.Entry[] create(int requestedSize) { * int)}. */ @Deprecated + @Nonnull public static Hashtable.Entry[] create(int requestedSize, float scale) { return new Entry[sizeFor((int) (requestedSize * scale))]; } @@ -664,7 +694,7 @@ static int sizeFor(int requestedSize) { * @deprecated use {@link Hashtable#clear(Hashtable.Entry[])}. */ @Deprecated - public static void clear(Hashtable.Entry[] buckets) { + public static void clear(@Nonnull Hashtable.Entry[] buckets) { Hashtable.clear(buckets); } @@ -672,8 +702,9 @@ public static void clear(Hashtable.Entry[] buckets) { * @deprecated use {@link Hashtable#bucketIterator(Hashtable.Entry[], long)}. */ @Deprecated + @Nonnull public static BucketIterator bucketIterator( - Hashtable.Entry[] buckets, long keyHash) { + @Nonnull Hashtable.Entry[] buckets, long keyHash) { return Hashtable.bucketIterator(buckets, keyHash); } @@ -681,9 +712,10 @@ public static BucketIterator bucketIter * @deprecated use {@link Hashtable#mutatingBucketIterator(Hashtable.Entry[], long)}. */ @Deprecated + @Nonnull public static MutatingBucketIterator mutatingBucketIterator( - Hashtable.Entry[] buckets, long keyHash) { + @Nonnull Hashtable.Entry[] buckets, long keyHash) { return Hashtable.mutatingBucketIterator(buckets, keyHash); } @@ -691,8 +723,9 @@ MutatingBucketIterator mutatingBucketIterator( * @deprecated use {@link Hashtable#mutatingTableIterator(Hashtable.Entry[])}. */ @Deprecated + @Nonnull public static - MutatingTableIterator mutatingTableIterator(Hashtable.Entry[] buckets) { + MutatingTableIterator mutatingTableIterator(@Nonnull Hashtable.Entry[] buckets) { return Hashtable.mutatingTableIterator(buckets); } @@ -700,9 +733,10 @@ MutatingTableIterator mutatingTableIterator(Hashtable.Entry[] buckets) { * @deprecated use {@link Hashtable#mutatingTableIterator(Hashtable.Entry[], int, int)}. */ @Deprecated + @Nonnull public static MutatingTableIterator mutatingTableIterator( - Hashtable.Entry[] buckets, int startBucket, int endBucket) { + @Nonnull Hashtable.Entry[] buckets, int startBucket, int endBucket) { return Hashtable.mutatingTableIterator(buckets, startBucket, endBucket); } @@ -710,7 +744,7 @@ MutatingTableIterator mutatingTableIterator( * @deprecated use {@link Hashtable#bucketIndex(Object[], long)}. */ @Deprecated - public static int bucketIndex(Object[] buckets, long keyHash) { + public static int bucketIndex(@Nonnull Object[] buckets, long keyHash) { return Hashtable.bucketIndex(buckets, keyHash); } @@ -719,7 +753,7 @@ public static int bucketIndex(Object[] buckets, long keyHash) { */ @Deprecated public static void insertHeadEntry( - Hashtable.Entry[] buckets, int bucketIndex, Hashtable.Entry entry) { + @Nonnull Hashtable.Entry[] buckets, int bucketIndex, @Nonnull Hashtable.Entry entry) { Hashtable.insertHeadEntry(buckets, bucketIndex, entry); } @@ -728,7 +762,7 @@ public static void insertHeadEntry( */ @Deprecated public static void insertHeadEntry( - Hashtable.Entry[] buckets, long keyHash, Hashtable.Entry entry) { + @Nonnull Hashtable.Entry[] buckets, long keyHash, @Nonnull Hashtable.Entry entry) { Hashtable.insertHeadEntry(buckets, keyHash, entry); } @@ -736,8 +770,9 @@ public static void insertHeadEntry( * @deprecated use {@link Hashtable#bucket(Hashtable.Entry[], long)}. */ @Deprecated + @Nullable public static TEntry bucket( - Hashtable.Entry[] buckets, long keyHash) { + @Nonnull Hashtable.Entry[] buckets, long keyHash) { return Hashtable.bucket(buckets, keyHash); } @@ -746,7 +781,7 @@ public static TEntry bucket( */ @Deprecated public static void forEach( - Hashtable.Entry[] buckets, Consumer consumer) { + @Nonnull Hashtable.Entry[] buckets, @Nonnull Consumer consumer) { Hashtable.forEach(buckets, consumer); } @@ -755,7 +790,9 @@ public static void forEach( */ @Deprecated public static void forEach( - Hashtable.Entry[] buckets, C context, BiConsumer consumer) { + @Nonnull Hashtable.Entry[] buckets, + C context, + @Nonnull BiConsumer consumer) { Hashtable.forEach(buckets, context, consumer); } } @@ -775,7 +812,7 @@ 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)]; while (cur != null && cur.keyHash != keyHash) { @@ -791,6 +828,7 @@ public boolean hasNext() { @Override @SuppressWarnings("unchecked") + @Nonnull public TEntry next() { Hashtable.Entry cur = this.nextEntry; if (cur == null) { @@ -837,7 +875,7 @@ 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; @@ -871,6 +909,7 @@ public boolean hasNext() { @Override @SuppressWarnings("unchecked") + @Nonnull public TEntry next() { Hashtable.Entry curEntry = this.nextEntry; if (curEntry == null) { @@ -915,7 +954,7 @@ public void remove() { this.curEntry = null; } - public void replace(TEntry replacementEntry) { + public void replace(@Nonnull TEntry replacementEntry) { Hashtable.Entry oldCurEntry = this.curEntry; if (oldCurEntry == null) { throw new IllegalStateException(); @@ -935,7 +974,7 @@ 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; @@ -992,7 +1031,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( @@ -1029,6 +1068,7 @@ public boolean hasNext() { @Override @SuppressWarnings("unchecked") + @Nonnull public TEntry next() { Hashtable.Entry e = this.nextEntry; if (e == null) { From 5a7d24589e51f3d925e5de9fc6147d0e38c908e2 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 20 Aug 2026 15:05:14 -0400 Subject: [PATCH 04/41] Rename Hashtable.insertHeadEntry overloads to insertHeadEntryAt/For Mirrors the ConcurrentHashtable fix: an int-typed key hash calling the overloaded insertHeadEntry(buckets, hash, entry) binds to the int-index overload instead of widening to long, treating the raw hash as an array index. Split into insertHeadEntryAt (index-based) and insertHeadEntryFor (hash-based). Also renames bucket to bucketFor for consistency with ConcurrentHashtable's naming, even though Hashtable has no competing int-index overload of bucket today. Co-Authored-By: Claude Sonnet 5 --- .../java/datadog/trace/util/Hashtable.java | 63 +++++++++++-------- .../datadog/trace/util/HashtableTest.java | 4 +- 2 files changed, 40 insertions(+), 27 deletions(-) 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 32351cbf208..43521797ff1 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -30,7 +30,7 @@ *

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) custom tables driven by the static * building blocks on this class (see {@link #createFixedBuckets(Class, int)}, {@link - * #bucket(Hashtable.Entry[], long)}, {@link #insertHeadEntry(Hashtable.Entry[], int, + * #bucketFor(Hashtable.Entry[], long)}, {@link #insertHeadEntryAt(Hashtable.Entry[], int, * Hashtable.Entry)}, and friends). The deprecated {@link Support} class is a thin facade over those * same statics, retained for source compatibility. */ @@ -161,7 +161,7 @@ public int size() { @Nullable public TEntry get(@Nullable K key) { long keyHash = D1.Entry.hash(key); - for (TEntry curEntry = bucket(this.buckets, keyHash); + for (TEntry curEntry = bucketFor(this.buckets, keyHash); curEntry != null; curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key)) { @@ -190,7 +190,7 @@ public TEntry remove(@Nullable K key) { } public void insert(@Nonnull TEntry newEntry) { - insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); this.size += 1; } @@ -207,7 +207,7 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { } } - insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); this.size += 1; return null; } @@ -226,7 +226,7 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { public TEntry getOrCreate( @Nullable K key, @Nonnull Function creator) { long keyHash = D1.Entry.hash(key); - for (TEntry curEntry = bucket(this.buckets, keyHash); + for (TEntry curEntry = bucketFor(this.buckets, keyHash); curEntry != null; curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key)) { @@ -234,7 +234,7 @@ public TEntry getOrCreate( } } TEntry newEntry = creator.apply(key); - insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); this.size += 1; return newEntry; } @@ -359,7 +359,7 @@ public int size() { @Nullable public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { long keyHash = D2.Entry.hash(key1, key2); - for (TEntry curEntry = bucket(this.buckets, keyHash); + for (TEntry curEntry = bucketFor(this.buckets, keyHash); curEntry != null; curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { @@ -388,7 +388,7 @@ public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { } public void insert(@Nonnull TEntry newEntry) { - insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); this.size += 1; } @@ -405,7 +405,7 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { } } - insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); this.size += 1; return null; } @@ -421,7 +421,7 @@ public TEntry getOrCreate( @Nullable K2 key2, @Nonnull BiFunction creator) { long keyHash = D2.Entry.hash(key1, key2); - for (TEntry curEntry = bucket(this.buckets, keyHash); + for (TEntry curEntry = bucketFor(this.buckets, keyHash); curEntry != null; curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { @@ -429,7 +429,7 @@ public TEntry getOrCreate( } } TEntry newEntry = creator.apply(key1, key2); - insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); this.size += 1; return newEntry; } @@ -523,10 +523,16 @@ public static int bucketIndex(@Nonnull Object[] buckets, long keyHash) { * 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 to match {@link ConcurrentHashtable#bucketFor} rather than {@code bucket}: this class + * has no competing {@code int}-index overload today, but naming it {@code bucketFor} up front + * keeps the two classes' static building blocks aligned and avoids reintroducing the {@code + * bucket}/{@code insertHeadEntry} int-vs-long overload ambiguity that {@link ConcurrentHashtable} + * had to rename its way out of. */ @SuppressWarnings("unchecked") @Nullable - public static TEntry bucket( + public static TEntry bucketFor( @Nonnull Hashtable.Entry[] buckets, long keyHash) { return (TEntry) buckets[bucketIndex(buckets, keyHash)]; } @@ -535,21 +541,27 @@ public static TEntry bucket( * 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 insertHeadEntry( + public static void insertHeadEntryAt( @Nonnull Hashtable.Entry[] buckets, int bucketIndex, @Nonnull Hashtable.Entry entry) { entry.setNext(buckets[bucketIndex]); 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. + * 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}) for the same reason {@link ConcurrentHashtable#insertHeadEntryFor} is: 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 insertHeadEntry( + public static void insertHeadEntryFor( @Nonnull Hashtable.Entry[] buckets, long keyHash, @Nonnull Hashtable.Entry entry) { - insertHeadEntry(buckets, bucketIndex(buckets, keyHash), entry); + insertHeadEntryAt(buckets, bucketIndex(buckets, keyHash), entry); } public static void clear(@Nonnull Hashtable.Entry[] buckets) { @@ -749,31 +761,32 @@ public static int bucketIndex(@Nonnull Object[] buckets, long keyHash) { } /** - * @deprecated use {@link Hashtable#insertHeadEntry(Hashtable.Entry[], int, Hashtable.Entry)}. + * @deprecated use {@link Hashtable#insertHeadEntryAt(Hashtable.Entry[], int, Hashtable.Entry)}. */ @Deprecated public static void insertHeadEntry( @Nonnull Hashtable.Entry[] buckets, int bucketIndex, @Nonnull Hashtable.Entry entry) { - Hashtable.insertHeadEntry(buckets, bucketIndex, entry); + Hashtable.insertHeadEntryAt(buckets, bucketIndex, entry); } /** - * @deprecated use {@link Hashtable#insertHeadEntry(Hashtable.Entry[], long, Hashtable.Entry)}. + * @deprecated use {@link Hashtable#insertHeadEntryFor(Hashtable.Entry[], long, + * Hashtable.Entry)}. */ @Deprecated public static void insertHeadEntry( @Nonnull Hashtable.Entry[] buckets, long keyHash, @Nonnull Hashtable.Entry entry) { - Hashtable.insertHeadEntry(buckets, keyHash, entry); + Hashtable.insertHeadEntryFor(buckets, keyHash, entry); } /** - * @deprecated use {@link Hashtable#bucket(Hashtable.Entry[], long)}. + * @deprecated use {@link Hashtable#bucketFor(Hashtable.Entry[], long)}. */ @Deprecated @Nullable public static TEntry bucket( @Nonnull Hashtable.Entry[] buckets, long keyHash) { - return Hashtable.bucket(buckets, keyHash); + return Hashtable.bucketFor(buckets, keyHash); } /** 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 f566d04ee0d..431145c7428 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -91,11 +91,11 @@ void insertHeadEntrySplicesAsNewHead() { Hashtable.Entry[] buckets = Hashtable.createFixedBuckets(StringIntEntry.class, 4); StringIntEntry a = new StringIntEntry("a", 1); StringIntEntry b = new StringIntEntry("b", 2); - Hashtable.insertHeadEntry(buckets, 0, a); + Hashtable.insertHeadEntryAt(buckets, 0, a); assertSame(a, buckets[0]); assertNull(a.next()); - Hashtable.insertHeadEntry(buckets, 0, b); + Hashtable.insertHeadEntryAt(buckets, 0, b); assertSame(b, buckets[0]); assertSame(a, b.next()); assertNull(a.next()); From 54a0760e5388c061da363546ee4fa0e3755e93ab Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 10:03:15 -0400 Subject: [PATCH 05/41] Add a strict entry-count cap to Hashtable.D1/D2 Capacity is now enforced, not just used to size the bucket array: insert() returns false, getOrCreate() returns null, and insertOrReplace() throws once size() reaches the constructor capacity. A lookup hit is still always returned even at capacity -- only new entries are blocked. Callers wanting their own eviction policy can drop to Hashtable.Support directly. --- .../java/datadog/trace/util/Hashtable.java | 69 ++++++++++++++++--- .../datadog/trace/util/HashtableD1Test.java | 43 ++++++++++++ .../datadog/trace/util/HashtableD2Test.java | 41 +++++++++++ 3 files changed, 144 insertions(+), 9 deletions(-) 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 43521797ff1..fcb22218ae2 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -76,8 +76,12 @@ 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 #getOrCreate} 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 + * {@link Hashtable.Support} and manage the bucket array yourself. 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}. @@ -134,10 +138,14 @@ public static long hash(@Nullable Object key) { // building blocks directly against the table's bucket array. final Hashtable.Entry[] buckets; private int size; + private final int limit; // hard cap on size public D1(int capacity) { - this.buckets = new Hashtable.Entry[sizeFor(capacity)]; + // Bucket array gets load-factor headroom over the strict entry cap below, so chains stay + // short even at capacity; see Hashtable#createFixedBuckets's javadoc for the same idiom. + this.buckets = new Hashtable.Entry[sizeFor((int) (capacity * 4 / 3f))]; this.size = 0; + this.limit = capacity; } /** @@ -189,11 +197,28 @@ public TEntry remove(@Nullable K key) { return null; } - public void insert(@Nonnull 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. + */ + public boolean insert(@Nonnull TEntry newEntry) { + if (this.size >= this.limit) { + return false; + } insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); this.size += 1; + return true; } + /** + * Replaces the existing entry for {@code newEntry}'s key (returning the prior entry), or + * inserts it fresh (returning {@code null}) if absent. Replacing never grows {@link #size()}, + * so it always succeeds even on a full table; only a fresh insert can hit the cap, in which + * case this throws {@link IllegalStateException} -- unlike {@link #insert} and {@link + * #getOrCreate}, there is no spare return-value slot free to signal refusal without colliding + * with the existing "freshly inserted" {@code null}. + */ @Nullable public TEntry insertOrReplace(@Nonnull TEntry newEntry) { for (MutatingBucketIterator iter = @@ -207,6 +232,9 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { } } + if (this.size >= this.limit) { + throw new IllegalStateException("Hashtable.D1 is at capacity (" + this.limit + ")"); + } insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); this.size += 1; return null; @@ -221,6 +249,9 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { * 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. + * + *

Returns {@code null} once the table is at capacity and {@code key} is absent -- a hit is + * always returned even at capacity, the cap only blocks new entries. */ @Nonnull public TEntry getOrCreate( @@ -233,6 +264,9 @@ public TEntry getOrCreate( return curEntry; } } + if (this.size >= this.limit) { + return null; + } TEntry newEntry = creator.apply(key); insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); this.size += 1; @@ -268,8 +302,8 @@ public void forEach(C context, @Nonnull BiConsumer} 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)}. @@ -332,10 +366,14 @@ public static long hash(@Nullable Object key1, @Nullable Object key2) { // Package-private to match D1.buckets -- available for iterator tests in the same package. final Hashtable.Entry[] buckets; private int size; + private final int limit; // hard cap on size public D2(int capacity) { - this.buckets = new Hashtable.Entry[sizeFor(capacity)]; + // Bucket array gets load-factor headroom over the strict entry cap below, so chains stay + // short even at capacity; see Hashtable#createFixedBuckets's javadoc for the same idiom. + this.buckets = new Hashtable.Entry[sizeFor((int) (capacity * 4 / 3f))]; this.size = 0; + this.limit = capacity; } /** @@ -387,11 +425,17 @@ public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { return null; } - public void insert(@Nonnull TEntry newEntry) { + /** Two-key analogue of {@link D1#insert}, with the same strict-cap refusal contract. */ + public boolean insert(@Nonnull TEntry newEntry) { + if (this.size >= this.limit) { + return false; + } insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); this.size += 1; + return true; } + /** Two-key analogue of {@link D1#insertOrReplace}, with the same refusal contract. */ @Nullable public TEntry insertOrReplace(@Nonnull TEntry newEntry) { for (MutatingBucketIterator iter = @@ -405,6 +449,9 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { } } + if (this.size >= this.limit) { + throw new IllegalStateException("Hashtable.D2 is at capacity (" + this.limit + ")"); + } insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); this.size += 1; return null; @@ -413,7 +460,8 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { /** * Two-key analogue of {@link D1#getOrCreate}. Computes the combined hash once and reuses it for * both lookup and (on miss) insert. The {@code creator} is expected to build an entry whose - * {@code keyHash} equals {@link Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}. + * {@code keyHash} equals {@link Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}. Same + * strict-cap refusal contract as {@link D1#getOrCreate}. */ @Nonnull public TEntry getOrCreate( @@ -428,6 +476,9 @@ public TEntry getOrCreate( return curEntry; } } + if (this.size >= this.limit) { + return null; + } TEntry newEntry = creator.apply(key1, key2); insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); this.size += 1; 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..4fa814c3a1a 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -4,9 +4,12 @@ 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; @@ -238,4 +241,44 @@ void getOrCreateNullKeyIsPermitted() { assertSame(created, table.getOrCreate(null, k -> new StringIntEntry(k, 999))); assertEquals(1, table.size()); } + + @Test + void insertReturnsFalseOnceAtCapacity() { + Hashtable.D1 table = new Hashtable.D1<>(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 = new Hashtable.D1<>(2); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + + assertNull(table.getOrCreate("c", k -> new StringIntEntry(k, 3))); + assertEquals(2, table.size()); + + StringIntEntry hit = table.getOrCreate("a", k -> new StringIntEntry(k, 999)); + assertEquals(1, hit.value, "existing entry is still returned even at capacity"); + } + + @Test + void insertOrReplaceStillReplacesAtCapacityButThrowsOnFreshInsert() { + Hashtable.D1 table = new Hashtable.D1<>(2); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + + StringIntEntry replacement = new StringIntEntry("a", 99); + StringIntEntry prior = table.insertOrReplace(replacement); + assertEquals(1, prior.value); + assertSame(replacement, table.get("a")); + assertEquals(2, table.size()); + + assertThrows( + IllegalStateException.class, () -> table.insertOrReplace(new StringIntEntry("c", 3))); + assertEquals(2, 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..8f7741c056c 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; @@ -182,6 +183,46 @@ void clearEmptiesTable() { assertNull(table.get("b", 2)); } + @Test + void insertReturnsFalseOnceAtCapacity() { + Hashtable.D2 table = new Hashtable.D2<>(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 = new Hashtable.D2<>(2); + table.insert(new PairEntry("a", 1, 100)); + table.insert(new PairEntry("b", 2, 200)); + + assertNull(table.getOrCreate("c", 3, (k1, k2) -> new PairEntry(k1, k2, 300))); + assertEquals(2, table.size()); + + PairEntry hit = table.getOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 999)); + assertEquals(100, hit.value, "existing entry is still returned even at capacity"); + } + + @Test + void insertOrReplaceStillReplacesAtCapacityButThrowsOnFreshInsert() { + Hashtable.D2 table = new Hashtable.D2<>(2); + table.insert(new PairEntry("a", 1, 100)); + table.insert(new PairEntry("b", 2, 200)); + + PairEntry replacement = new PairEntry("a", 1, 999); + PairEntry prior = table.insertOrReplace(replacement); + assertEquals(100, prior.value); + assertSame(replacement, table.get("a", 1)); + assertEquals(2, table.size()); + + assertThrows( + IllegalStateException.class, () -> table.insertOrReplace(new PairEntry("c", 3, 300))); + assertEquals(2, table.size()); + } + private static final class PairEntry extends Hashtable.D2.Entry { int value; From c4c230de1f0921e99af0b1b27527c3161b06ac31 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 10:03:23 -0400 Subject: [PATCH 06/41] Handle Hashtable.D1's new strict cap in CardinalityLimitReporter getOrCreate() can now return null once TAG_CAPACITY distinct tags are blocked in a window; record() must null-check it rather than relying on the table's old unbounded-chaining behavior. --- .../common/metrics/CardinalityLimitReporter.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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..526fbe69e10 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 @@ -56,7 +57,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.getOrCreate(tag, TagBlockEntry::new); + if (entry != null) { + entry.count += count; + } } } From 0d2491de3c7d7e11b7a91e59c0eafddb2927f3b3 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 12:17:31 -0400 Subject: [PATCH 07/41] Add Hashtable.SizeTracker, EvictionCursor, and Table building blocks Composers driving the static building blocks directly (e.g. client-side stats' AggregateTable) currently hand-roll entry-count bookkeeping and cursor-resumed eviction scans themselves. These give them (and D1/D2, next) a shared, non-thread-safe primitive for both instead. Co-Authored-By: Claude Sonnet 5 --- .../java/datadog/trace/util/Hashtable.java | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) 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 fcb22218ae2..d8946ceb8b6 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -8,6 +8,7 @@ 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; @@ -692,6 +693,179 @@ MutatingTableIterator mutatingTableIterator( return new MutatingTableIterator(buckets, startBucket, endBucket); } + /** + * Tracks a live entry count against a fixed capacity. {@link D1} and {@link D2} use this + * internally for their strict entry-count cap; other composers of the static building blocks + * above -- e.g. client-side stats' {@code AggregateTable}, which drives a {@code + * Hashtable.Entry[]} directly -- can reuse it instead of hand-rolling the same + * increment/decrement/cap-check bookkeeping. + * + *

Not thread-safe, matching the rest of this class. + */ + public static final class SizeTracker { + private final int capacity; + private int size; + + public SizeTracker(int capacity) { + this.capacity = capacity; + } + + public int size() { + return this.size; + } + + public int capacity() { + return this.capacity; + } + + /** {@code true} once {@link #size()} has reached {@link #capacity()}. */ + public boolean isFull() { + return this.size >= this.capacity; + } + + /** + * 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) -- e.g. {@link + * D1#insert}. When building the entry is itself fallible (e.g. {@link D1#getOrCreate}'s {@code + * creator}), check {@link #isFull()} first, do the fallible work, then call {@link #increment()} + * only once linking actually succeeds. + * + *

Returning {@code false} here is not a final refusal -- it's the caller's cue to either + * refuse the insert, or make room (e.g. evict a stale entry via {@link EvictionCursor}) and + * retry. + */ + public boolean tryReserve() { + if (isFull()) { + return false; + } + 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; + } + + public void reset() { + this.size = 0; + } + } + + /** + * Resumable cursor for scanning a bucket array to evict entries under a caller-supplied {@link + * Predicate}, without repeatedly re-scanning the same already-checked prefix on a sustained + * eviction stream. + * + *

Pairs with {@link SizeTracker}: when {@link SizeTracker#tryReserve()} refuses because the + * table is full, a composer can call {@link #evictOne} to make room and retry, or give up if + * nothing was evictable. Factored out of client-side stats' {@code AggregateTable}, which + * originally hand-rolled this same cursor-resumed two-pass scan. + * + *

Not thread-safe, matching the rest of this class. + */ + public static final class EvictionCursor { + private int cursor; + + /** + * Scans {@code buckets} for the first entry matching {@code evictable}, starting at the cursor + * and wrapping all the way around back to the cursor if needed. Unlinks and returns the + * evicted entry, resuming the next call's scan from just past it; returns {@code null} if no + * entry matched anywhere in the table. + */ + @Nullable + public Entry evictOne( + @Nonnull Hashtable.Entry[] buckets, @Nonnull Predicate evictable) { + Entry evicted = evictOneInRange(buckets, evictable, this.cursor, buckets.length); + if (evicted == null && this.cursor != 0) { + evicted = evictOneInRange(buckets, evictable, 0, this.cursor); + } + return evicted; + } + + @Nullable + private Entry evictOneInRange( + @Nonnull Hashtable.Entry[] buckets, + @Nonnull Predicate evictable, + int startBucket, + int endBucket) { + MutatingTableIterator iter = mutatingTableIterator(buckets, startBucket, endBucket); + while (iter.hasNext()) { + Entry candidate = iter.next(); + if (evictable.test(candidate)) { + int bucket = iter.currentBucket(); + iter.remove(); + this.cursor = bucket; + return candidate; + } + } + return null; + } + + /** + * Unlinks every entry matching {@code evictable} in a single full pass over {@code buckets}, + * regardless of the cursor's current position, and returns how many were removed. Resets the + * cursor to the start, since a full pass leaves nothing later to resume from. + */ + public int drain( + @Nonnull Hashtable.Entry[] buckets, @Nonnull Predicate evictable) { + int count = 0; + MutatingTableIterator iter = mutatingTableIterator(buckets); + while (iter.hasNext()) { + Entry candidate = iter.next(); + if (evictable.test(candidate)) { + iter.remove(); + count++; + } + } + this.cursor = 0; + return count; + } + + public void reset() { + this.cursor = 0; + } + } + + /** + * Bundles a bucket array together with a {@link SizeTracker} and {@link EvictionCursor} sized + * and matched to it, so a composer driving the static building blocks directly (e.g. + * client-side stats' {@code AggregateTable}) gets everything it needs to store from one factory + * call, instead of separately sizing an array and a tracker that must stay in sync with it. Same + * headroom idiom as {@link D1}/{@link D2}'s constructors: {@code capacity} is the strict cap on + * live entries, and the backing array is sized with load-factor headroom over it. + * + *

Store the pieces of this bundle into your own fields; nothing here is meant to be held onto + * as a {@code Table} itself. + */ + public static final class Table { + public final Hashtable.Entry[] buckets; + public final SizeTracker size; + public final EvictionCursor evictionCursor = new EvictionCursor(); + + private Table(Hashtable.Entry[] buckets, int capacity) { + this.buckets = buckets; + this.size = new SizeTracker(capacity); + } + } + + /** + * Creates a {@link Table}: a bucket array sized with load-factor headroom over {@code capacity}, + * paired with a {@link SizeTracker} capped at the strict {@code capacity} and a fresh {@link + * EvictionCursor}. + */ + @Nonnull + public static Table createTable(int capacity) { + Hashtable.Entry[] buckets = new Hashtable.Entry[sizeFor((int) (capacity * 4 / 3f))]; + return new Table(buckets, capacity); + } + /** * Deprecated facade over the static building blocks that are now methods on {@link Hashtable} * itself (mirroring the concurrent variant). Each method here delegates to its {@code From c04c3ded274d8d16436614d73240e62555965edf Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 12:18:05 -0400 Subject: [PATCH 08/41] Back Hashtable.D1/D2's entry-count cap with SizeTracker Replaces the hand-rolled size/limit int fields with the new shared SizeTracker -- no behavior change, D1/D2's public API and semantics are unchanged. Co-Authored-By: Claude Sonnet 5 --- .../java/datadog/trace/util/Hashtable.java | 50 ++++++++----------- 1 file changed, 22 insertions(+), 28 deletions(-) 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 d8946ceb8b6..31304edbcd3 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -138,15 +138,13 @@ public static long hash(@Nullable Object key) { // 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 int limit; // hard cap on size + private final SizeTracker sizeTracker; public D1(int capacity) { // Bucket array gets load-factor headroom over the strict entry cap below, so chains stay // short even at capacity; see Hashtable#createFixedBuckets's javadoc for the same idiom. this.buckets = new Hashtable.Entry[sizeFor((int) (capacity * 4 / 3f))]; - this.size = 0; - this.limit = capacity; + this.sizeTracker = new SizeTracker(capacity); } /** @@ -164,7 +162,7 @@ public static > D1 createFixedBuckets( } public int size() { - return this.size; + return this.sizeTracker.size(); } @Nullable @@ -190,7 +188,7 @@ public TEntry remove(@Nullable K key) { if (curEntry.matches(key)) { iter.remove(); - this.size -= 1; + this.sizeTracker.decrement(); return curEntry; } } @@ -204,11 +202,10 @@ public TEntry remove(@Nullable K key) { * shadowed behind the existing entry. */ public boolean insert(@Nonnull TEntry newEntry) { - if (this.size >= this.limit) { + if (!this.sizeTracker.tryReserve()) { return false; } insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; return true; } @@ -233,11 +230,11 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { } } - if (this.size >= this.limit) { - throw new IllegalStateException("Hashtable.D1 is at capacity (" + this.limit + ")"); + if (!this.sizeTracker.tryReserve()) { + throw new IllegalStateException( + "Hashtable.D1 is at capacity (" + this.sizeTracker.capacity() + ")"); } insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; return null; } @@ -265,18 +262,18 @@ public TEntry getOrCreate( return curEntry; } } - if (this.size >= this.limit) { + if (this.sizeTracker.isFull()) { return null; } TEntry newEntry = creator.apply(key); insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; + this.sizeTracker.increment(); return newEntry; } public void clear() { Hashtable.clear(this.buckets); - this.size = 0; + this.sizeTracker.reset(); } public void forEach(@Nonnull Consumer consumer) { @@ -366,15 +363,13 @@ public static long hash(@Nullable Object key1, @Nullable Object key2) { // Package-private to match D1.buckets -- available for iterator tests in the same package. final Hashtable.Entry[] buckets; - private int size; - private final int limit; // hard cap on size + private final SizeTracker sizeTracker; public D2(int capacity) { // Bucket array gets load-factor headroom over the strict entry cap below, so chains stay // short even at capacity; see Hashtable#createFixedBuckets's javadoc for the same idiom. this.buckets = new Hashtable.Entry[sizeFor((int) (capacity * 4 / 3f))]; - this.size = 0; - this.limit = capacity; + this.sizeTracker = new SizeTracker(capacity); } /** @@ -392,7 +387,7 @@ public static > D2 creat } public int size() { - return this.size; + return this.sizeTracker.size(); } @Nullable @@ -418,7 +413,7 @@ public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { if (curEntry.matches(key1, key2)) { iter.remove(); - this.size -= 1; + this.sizeTracker.decrement(); return curEntry; } } @@ -428,11 +423,10 @@ public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { /** Two-key analogue of {@link D1#insert}, with the same strict-cap refusal contract. */ public boolean insert(@Nonnull TEntry newEntry) { - if (this.size >= this.limit) { + if (!this.sizeTracker.tryReserve()) { return false; } insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; return true; } @@ -450,11 +444,11 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { } } - if (this.size >= this.limit) { - throw new IllegalStateException("Hashtable.D2 is at capacity (" + this.limit + ")"); + if (!this.sizeTracker.tryReserve()) { + throw new IllegalStateException( + "Hashtable.D2 is at capacity (" + this.sizeTracker.capacity() + ")"); } insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; return null; } @@ -477,18 +471,18 @@ public TEntry getOrCreate( return curEntry; } } - if (this.size >= this.limit) { + if (this.sizeTracker.isFull()) { return null; } TEntry newEntry = creator.apply(key1, key2); insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; + this.sizeTracker.increment(); return newEntry; } public void clear() { Hashtable.clear(this.buckets); - this.size = 0; + this.sizeTracker.reset(); } public void forEach(@Nonnull Consumer consumer) { From 96dd24970d5fd51ca84ba1bc357f18078a62f746 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 12:34:37 -0400 Subject: [PATCH 09/41] Port drain from ConcurrentHashtable to Hashtable Adds unconditional drain (forEach-then-clear-and-reset-size in one call, plus a context-passing overload) as a static building block on Hashtable and as instance methods on D1/D2, mirroring ConcurrentHashtable's drain(Consumer)/drain(context, BiConsumer). The single-threaded version needs no locking, just a size-tracker reset. Co-Authored-By: Claude Sonnet 5 --- .../java/datadog/trace/util/Hashtable.java | 87 ++++++++++++++++--- .../datadog/trace/util/HashtableD1Test.java | 44 ++++++++++ .../datadog/trace/util/HashtableD2Test.java | 39 +++++++++ .../datadog/trace/util/HashtableTest.java | 15 ++++ 4 files changed, 174 insertions(+), 11 deletions(-) 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 31304edbcd3..a6b759f14e2 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -288,6 +288,26 @@ public void forEach(@Nonnull Consumer consumer) { public void forEach(C context, @Nonnull BiConsumer consumer) { Hashtable.forEach(this.buckets, context, consumer); } + + /** + * 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.buckets, sink); + this.sizeTracker.reset(); + } + + /** + * 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.buckets, context, sink); + this.sizeTracker.reset(); + } } /** @@ -497,6 +517,26 @@ public void forEach(@Nonnull Consumer consumer) { public void forEach(C context, @Nonnull BiConsumer consumer) { Hashtable.forEach(this.buckets, context, consumer); } + + /** + * 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.buckets, sink); + this.sizeTracker.reset(); + } + + /** + * 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.buckets, context, sink); + this.sizeTracker.reset(); + } } // ============================================================================================ @@ -646,6 +686,31 @@ public static void forEach( } } + /** + * 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. + */ + public static void drain( + @Nonnull Hashtable.Entry[] buckets, @Nonnull Consumer sink) { + Hashtable.forEach(buckets, sink); + clear(buckets); + } + + /** + * 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. + */ + public static void drain( + @Nonnull Hashtable.Entry[] buckets, + C context, + @Nonnull BiConsumer sink) { + Hashtable.forEach(buckets, context, sink); + clear(buckets); + } + @Nonnull public static BucketIterator bucketIterator( @Nonnull Hashtable.Entry[] buckets, long keyHash) { @@ -722,8 +787,8 @@ public boolean isFull() { * 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) -- e.g. {@link * D1#insert}. When building the entry is itself fallible (e.g. {@link D1#getOrCreate}'s {@code - * creator}), check {@link #isFull()} first, do the fallible work, then call {@link #increment()} - * only once linking actually succeeds. + * creator}), check {@link #isFull()} first, do the fallible work, then call {@link + * #increment()} only once linking actually succeeds. * *

Returning {@code false} here is not a final refusal -- it's the caller's cue to either * refuse the insert, or make room (e.g. evict a stale entry via {@link EvictionCursor}) and @@ -769,9 +834,9 @@ public static final class EvictionCursor { /** * Scans {@code buckets} for the first entry matching {@code evictable}, starting at the cursor - * and wrapping all the way around back to the cursor if needed. Unlinks and returns the - * evicted entry, resuming the next call's scan from just past it; returns {@code null} if no - * entry matched anywhere in the table. + * and wrapping all the way around back to the cursor if needed. Unlinks and returns the evicted + * entry, resuming the next call's scan from just past it; returns {@code null} if no entry + * matched anywhere in the table. */ @Nullable public Entry evictOne( @@ -828,12 +893,12 @@ public void reset() { } /** - * Bundles a bucket array together with a {@link SizeTracker} and {@link EvictionCursor} sized - * and matched to it, so a composer driving the static building blocks directly (e.g. - * client-side stats' {@code AggregateTable}) gets everything it needs to store from one factory - * call, instead of separately sizing an array and a tracker that must stay in sync with it. Same - * headroom idiom as {@link D1}/{@link D2}'s constructors: {@code capacity} is the strict cap on - * live entries, and the backing array is sized with load-factor headroom over it. + * Bundles a bucket array together with a {@link SizeTracker} and {@link EvictionCursor} sized and + * matched to it, so a composer driving the static building blocks directly (e.g. client-side + * stats' {@code AggregateTable}) gets everything it needs to store from one factory call, instead + * of separately sizing an array and a tracker that must stay in sync with it. Same headroom idiom + * as {@link D1}/{@link D2}'s constructors: {@code capacity} is the strict cap on live entries, + * and the backing array is sized with load-factor headroom over it. * *

Store the pieces of this bundle into your own fields; nothing here is meant to be held onto * as a {@code Table} itself. 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 4fa814c3a1a..68811ea80f6 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -281,4 +281,48 @@ void insertOrReplaceStillReplacesAtCapacityButThrowsOnFreshInsert() { IllegalStateException.class, () -> table.insertOrReplace(new StringIntEntry("c", 3))); assertEquals(2, table.size()); } + + @Test + void drainVisitsEveryEntryThenEmptiesTable() { + Hashtable.D1 table = new Hashtable.D1<>(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 = new Hashtable.D1<>(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 = new Hashtable.D1<>(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 8f7741c056c..dccaea700d2 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java @@ -223,6 +223,45 @@ void insertOrReplaceStillReplacesAtCapacityButThrowsOnFreshInsert() { assertEquals(2, table.size()); } + @Test + void drainVisitsEveryEntryThenEmptiesTable() { + Hashtable.D2 table = new Hashtable.D2<>(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 = new Hashtable.D2<>(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 = new Hashtable.D2<>(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 431145c7428..e4cec857bd7 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -86,6 +86,21 @@ void clearNullsAllBuckets() { } } + @Test + void drainVisitsEveryEntryThenClears() { + Hashtable.Entry[] buckets = Hashtable.createFixedBuckets(StringIntEntry.class, 4); + 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.Entry[] buckets = Hashtable.createFixedBuckets(StringIntEntry.class, 4); From e64b38478d87ae2b37f18aae204e77454fa1f54a Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 12:37:00 -0400 Subject: [PATCH 10/41] Expose isFull on D1/D2 Delegates to the internal SizeTracker so callers can check capacity before calling insert/getOrCreate/insertOrReplace, instead of inferring it from a false/null/thrown result after the fact. Co-Authored-By: Claude Sonnet 5 --- .../src/main/java/datadog/trace/util/Hashtable.java | 10 ++++++++++ .../java/datadog/trace/util/HashtableD1Test.java | 12 ++++++++++++ .../java/datadog/trace/util/HashtableD2Test.java | 12 ++++++++++++ 3 files changed, 34 insertions(+) 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 a6b759f14e2..c909edf0da6 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -165,6 +165,11 @@ public int size() { return this.sizeTracker.size(); } + /** {@code true} once {@link #size()} has reached this table's fixed capacity. */ + public boolean isFull() { + return this.sizeTracker.isFull(); + } + @Nullable public TEntry get(@Nullable K key) { long keyHash = D1.Entry.hash(key); @@ -410,6 +415,11 @@ public int size() { return this.sizeTracker.size(); } + /** {@code true} once {@link #size()} has reached this table's fixed capacity. */ + public boolean isFull() { + return this.sizeTracker.isFull(); + } + @Nullable public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { long keyHash = D2.Entry.hash(key1, key2); 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 68811ea80f6..abd802182f2 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -282,6 +282,18 @@ void insertOrReplaceStillReplacesAtCapacityButThrowsOnFreshInsert() { assertEquals(2, table.size()); } + @Test + void isFullReflectsCapacity() { + Hashtable.D1 table = new Hashtable.D1<>(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 = new Hashtable.D1<>(8); 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 dccaea700d2..566e603da4a 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java @@ -223,6 +223,18 @@ void insertOrReplaceStillReplacesAtCapacityButThrowsOnFreshInsert() { assertEquals(2, table.size()); } + @Test + void isFullReflectsCapacity() { + Hashtable.D2 table = new Hashtable.D2<>(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 = new Hashtable.D2<>(8); From 5543d30602028de906fe4857d0750a7701d908f1 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 16:00:54 -0400 Subject: [PATCH 11/41] Mark Hashtable D1/D2 getOrCreate as @Nullable Both methods are annotated @Nonnull but return null once the table is at capacity and the key is absent -- which their own javadoc documents. The annotation contradicted the contract, on the exact path a capped table takes under pressure. Co-Authored-By: Claude Opus 5 (1M context) --- internal-api/src/main/java/datadog/trace/util/Hashtable.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 c909edf0da6..7c4c875ea83 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -256,7 +256,7 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { *

Returns {@code null} once the table is at capacity and {@code key} is absent -- a hit is * always returned even at capacity, the cap only blocks new entries. */ - @Nonnull + @Nullable public TEntry getOrCreate( @Nullable K key, @Nonnull Function creator) { long keyHash = D1.Entry.hash(key); @@ -488,7 +488,7 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { * {@code keyHash} equals {@link Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}. Same * strict-cap refusal contract as {@link D1#getOrCreate}. */ - @Nonnull + @Nullable public TEntry getOrCreate( @Nullable K1 key1, @Nullable K2 key2, From b3e59f39cff0ad17735a6d717b0eca39166e62fd Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 16:02:51 -0400 Subject: [PATCH 12/41] Unify the Hashtable factory API on a capped/uncapped vocabulary "Fixed" meant two contradictory things across the *Hashtable family: FlatHashtable.createFixed and Hashtable.D1 both cap entries and refuse past the cap, while ConcurrentHashtable's fixed tables have no cap at all and never refuse. Rename the capping factories to say what they promise rather than how they are built, so a caller reading the name gets a straight answer to "will this refuse my insert?". D1/D2.createFixed -> createCapped(entryClass, maxCapacity) Hashtable.createTable -> createCappedTable(maxCapacity) Table factories now always take a number of entries; only the low-level allocator takes buckets. Sizing a table from a bucket count is the HashMap(initialCapacity) footgun, and the load factor differs per class, so callers should never need to know it: Hashtable.create(int buckets) / create(Class, int buckets) -- low level Hashtable.capacityFor(cardinalityLimit[, loadFactor]) -- the bridge Hashtable.DEFAULT_LOAD_FACTOR -- 0.75, chained D1/D2 constructors become private so the factory always carries the posture choice, which also leaves room for a growable variant later without a second rename. The deprecated Support facade is inverted onto the blessed statics: the new untyped create(int) gives create(int)/create(int, float)/MAX_RATIO a real home, three inline `new Hashtable.Entry[...]` sites route through it, and the iterators stop calling Support.bucketIndex. Support now holds no logic and can be deleted outright once client-side stats migrates. FlatHashtable is unchanged -- it already used this shape, and keeps fixed/growable because for open addressing growth is a correctness requirement rather than a performance choice. Co-Authored-By: Claude Opus 5 (1M context) --- .../metrics/CardinalityLimitReporter.java | 3 +- .../trace/util/HashtableD1Benchmark.java | 2 +- .../trace/util/HashtableD2Benchmark.java | 2 +- .../java/datadog/trace/util/Hashtable.java | 289 +++++++++++----- .../datadog/trace/util/HashtableD1Test.java | 51 +-- .../datadog/trace/util/HashtableD2Test.java | 32 +- .../datadog/trace/util/HashtableTest.java | 322 ++++++++++++++++-- 7 files changed, 544 insertions(+), 157 deletions(-) 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 526fbe69e10..2fb446b1652 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 @@ -44,7 +44,8 @@ final class CardinalityLimitReporter { private final RatelimitedLogger rlLog; // Tag name -> blocked count accumulated since the last emitted summary. - private final Hashtable.D1 blockedByTag = new Hashtable.D1<>(TAG_CAPACITY); + private final Hashtable.D1 blockedByTag = + Hashtable.D1.createCapped(TagBlockEntry.class, TAG_CAPACITY); CardinalityLimitReporter() { this(new RatelimitedLogger(log, 5, MINUTES)); diff --git a/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java index 9581a8db520..ac22417c597 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java @@ -147,7 +147,7 @@ public static class D1State { public void setUp() { BenchmarkUtils.polluteHashDispatch(); - table = new Hashtable.D1<>(CAPACITY); + table = Hashtable.D1.createCapped(D1Counter.class, CAPACITY); hashMap = new HashMap<>(CAPACITY); keys = SOURCE_KEYS; for (int i = 0; i < N_KEYS; ++i) { diff --git a/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java index 49357ab9a17..bed64d7a613 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java @@ -183,7 +183,7 @@ public static class D2State { public void setUp() { BenchmarkUtils.polluteHashDispatch(); - table = new Hashtable.D2<>(CAPACITY); + table = Hashtable.D2.createCapped(D2Counter.class, CAPACITY); hashMap = new HashMap<>(CAPACITY); k1s = SOURCE_K1; k2s = SOURCE_K2; diff --git a/internal-api/src/main/java/datadog/trace/util/Hashtable.java b/internal-api/src/main/java/datadog/trace/util/Hashtable.java index 7c4c875ea83..e9b1aa60313 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; @@ -30,7 +31,7 @@ * *

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) custom tables driven by the static - * building blocks on this class (see {@link #createFixedBuckets(Class, int)}, {@link + * building blocks on this class (see {@link #create(Class, int)}, {@link * #bucketFor(Hashtable.Entry[], long)}, {@link #insertHeadEntryAt(Hashtable.Entry[], int, * Hashtable.Entry)}, and friends). The deprecated {@link Support} class is a thin facade over those * same statics, retained for source compatibility. @@ -140,25 +141,45 @@ public static long hash(@Nullable Object key) { final Hashtable.Entry[] buckets; private final SizeTracker sizeTracker; - public D1(int capacity) { + private D1(int maxCapacity) { // Bucket array gets load-factor headroom over the strict entry cap below, so chains stay - // short even at capacity; see Hashtable#createFixedBuckets's javadoc for the same idiom. - this.buckets = new Hashtable.Entry[sizeFor((int) (capacity * 4 / 3f))]; - this.sizeTracker = new SizeTracker(capacity); + // short even when the table is full; see Hashtable#capacityFor. + this.buckets = Hashtable.create(capacityFor(maxCapacity)); + this.sizeTracker = new SizeTracker(maxCapacity); } /** - * Creates a single-key table with a fixed bucket count sized for {@code capacity} entries. The - * {@code entryClass} pins the concrete entry type so the compiler infers both {@code K} and - * {@code TEntry} at the call site -- e.g. {@code D1.createFixedBuckets(MyEntry.class, 64)} -- - * keeping the factory symmetric with the rest of the flat-collections family (see {@link - * Hashtable#createFixedBuckets(Class, int)} for why the class isn't otherwise consumed). - * Capacity is fixed; the table does not resize. + * A capped single-key table: it holds at most {@code maxCapacity} live entries, after + * which {@link #insert} returns {@code false} and {@link #getOrCreate} 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 + * SizeTracker} with an {@link EvictionCursor} over the static building blocks (see {@link + * Hashtable#createCappedTable(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 SizeTracker(limit)}. + * + *

{@code entryClass} is a type token only -- it pins the concrete entry type so the compiler + * infers both {@code K} and {@code TEntry} at the call site (e.g. {@code + * D1.createCapped(MyEntry.class, 64)}), keeping the factory symmetric with the rest of the + * collections family. Unlike {@link Hashtable#create(Class, int)} it is not reflectively + * allocated: {@code buckets} stays a plain {@code Hashtable.Entry[]} internally, matching the + * static building blocks ({@link Hashtable#bucketFor}, {@link Hashtable#insertHeadEntryFor}, + * etc.) that {@link #get}, {@link #insert}, and friends delegate to. */ @Nonnull - public static > D1 createFixedBuckets( - @Nonnull Class entryClass, int capacity) { - return new D1<>(capacity); + public static > D1 createCapped( + @Nonnull Class entryClass, int maxCapacity) { + return new D1<>(maxCapacity); } public int size() { @@ -186,19 +207,7 @@ public TEntry get(@Nullable K key) { @Nullable public TEntry remove(@Nullable K key) { long keyHash = D1.Entry.hash(key); - - for (MutatingBucketIterator iter = mutatingBucketIterator(this.buckets, keyHash); - iter.hasNext(); ) { - TEntry curEntry = iter.next(); - - if (curEntry.matches(key)) { - iter.remove(); - this.sizeTracker.decrement(); - return curEntry; - } - } - - return null; + return removeMatching(this.buckets, keyHash, e -> e.matches(key), this.sizeTracker); } /** @@ -207,11 +216,7 @@ public TEntry remove(@Nullable K key) { * shadowed behind the existing entry. */ public boolean insert(@Nonnull TEntry newEntry) { - if (!this.sizeTracker.tryReserve()) { - return false; - } - insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); - return true; + return insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry, this.sizeTracker); } /** @@ -390,25 +395,30 @@ public static long hash(@Nullable Object key1, @Nullable Object key2) { final Hashtable.Entry[] buckets; private final SizeTracker sizeTracker; - public D2(int capacity) { + private D2(int maxCapacity) { // Bucket array gets load-factor headroom over the strict entry cap below, so chains stay - // short even at capacity; see Hashtable#createFixedBuckets's javadoc for the same idiom. - this.buckets = new Hashtable.Entry[sizeFor((int) (capacity * 4 / 3f))]; - this.sizeTracker = new SizeTracker(capacity); + // short even when the table is full; see Hashtable#capacityFor. + this.buckets = Hashtable.create(capacityFor(maxCapacity)); + this.sizeTracker = new SizeTracker(maxCapacity); } /** - * Creates a composite-key table with a fixed bucket count sized for {@code capacity} entries. - * The {@code entryClass} pins the concrete entry type so the compiler infers {@code K1}, {@code - * K2}, and {@code TEntry} at the call site -- e.g. {@code D2.createFixedBuckets(MyEntry.class, - * 64)} -- keeping the factory symmetric with the rest of the flat-collections family (see - * {@link Hashtable#createFixedBuckets(Class, int)} for why the class isn't otherwise consumed). - * Capacity is fixed; the table does not resize. + * Composite-key analogue of {@link D1#createCapped}: a capped table holding at most + * {@code maxCapacity} live entries, after which {@link #insert} returns {@code false} and + * {@link #getOrCreate} returns {@code null}, with lookup hits still always returned. See {@link + * D1#createCapped} for what "capped" promises and why it is the default posture. + * + *

{@code entryClass} is a type token only -- it pins the concrete entry type so the compiler + * infers {@code K1}, {@code K2}, and {@code TEntry} at the call site (e.g. {@code + * D2.createCapped(MyEntry.class, 64)}). Unlike {@link Hashtable#create(Class, int)} it is not + * reflectively allocated: {@code buckets} stays a plain {@code Hashtable.Entry[]} internally, + * matching the static building blocks that {@link #get}, {@link #insert}, and friends delegate + * to. */ @Nonnull - public static > D2 createFixedBuckets( - @Nonnull Class entryClass, int capacity) { - return new D2<>(capacity); + public static > D2 createCapped( + @Nonnull Class entryClass, int maxCapacity) { + return new D2<>(maxCapacity); } public int size() { @@ -436,28 +446,12 @@ public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { @Nullable public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { long keyHash = D2.Entry.hash(key1, key2); - - for (MutatingBucketIterator iter = mutatingBucketIterator(this.buckets, keyHash); - iter.hasNext(); ) { - TEntry curEntry = iter.next(); - - if (curEntry.matches(key1, key2)) { - iter.remove(); - this.sizeTracker.decrement(); - return curEntry; - } - } - - return null; + return removeMatching(this.buckets, keyHash, e -> e.matches(key1, key2), this.sizeTracker); } /** Two-key analogue of {@link D1#insert}, with the same strict-cap refusal contract. */ public boolean insert(@Nonnull TEntry newEntry) { - if (!this.sizeTracker.tryReserve()) { - return false; - } - insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); - return true; + return insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry, this.sizeTracker); } /** Two-key analogue of {@link D1#insertOrReplace}, with the same refusal contract. */ @@ -573,22 +567,77 @@ public void drain(C context, @Nonnull BiConsumer * Allocates a fixed-size bucket array sized to hold {@code capacity} entries: {@code capacity} * rounded up to the next power of two. * - *

Returns a concrete {@code Hashtable.Entry[]} (chain heads are stored at the base type), so - * the array assigns directly to a caller's {@code Hashtable.Entry[]} field. As with the - * concurrent variant's {@code createFixedBuckets}, {@code entryClass} is not consumed to - * allocate -- the array is a heterogeneous {@code Entry[]}, not a reflectively-allocated {@code - * TEntry[]}. It is accepted only to keep the factory call-shape symmetric across the - * flat-collections family ({@code createFixedBuckets(MyEntry.class, n)}). Capacity is fixed; the - * table does not resize. + *

Unlike the concurrent variant's {@code createFixedBuckets} (whose {@code + * AtomicReferenceArray} spine has an erased element type), this class's spine is a genuine {@code + * E[]}, so {@code entryClass} is reflectively allocated into it via {@link Array#newInstance} -- + * same idiom as {@code FlatHashtable#create(Class, int)}. That gives the returned array 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. Capacity is fixed; the table + * does not resize. * - *

For load-factor headroom over a target working-set size, size {@code capacity} yourself - * (e.g. {@code createFixedBuckets(MyEntry.class, (int) (n * 4 / 3f))}); the deprecated {@link - * Support#create(int, float)} bundled that scaling but has no blessed equivalent. + *

{@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 + * #createCappedTable} size themselves), pass {@link #capacityFor(int)} instead: {@code + * create(MyEntry.class, capacityFor(cardinalityLimit))}. */ + @SuppressWarnings("unchecked") @Nonnull - public static Hashtable.Entry[] createFixedBuckets( + public static TEntry[] create( @Nonnull Class entryClass, int capacity) { - return new Entry[sizeFor(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. + * + *

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 + * #createCappedTable} 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. + */ + @Nonnull + public static Hashtable.Entry[] create(int buckets) { + return new Hashtable.Entry[sizeFor(buckets)]; + } + + /** + * Balanced default load factor for a chained bucket array: at this target fill, chains from a + * well-spread hash stay short (average chain length {@code ~1/DEFAULT_LOAD_FACTOR}) without + * over-provisioning the array. Mirrors {@code FlatHashtable#DEFAULT_LOAD_FACTOR} in spirit, + * though the two aren't comparable numerically -- chaining degrades gracefully past 1.0 fill + * (longer chains, not failure), unlike open addressing, so this class can run a higher target + * fill than {@code FlatHashtable}'s. + */ + 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 #createCappedTable} all size themselves this way). Pair with a {@link SizeTracker} of + * {@code cardinalityLimit} for the matching strict cap; this method only sizes the array. + */ + public static int capacityFor(int cardinalityLimit) { + return capacityFor(cardinalityLimit, DEFAULT_LOAD_FACTOR); + } + + /** + * {@link #capacityFor(int)} at an explicit {@code loadFactor} in {@code (0, 1)}: the bucket-array + * length for a strict cap of {@code cardinalityLimit} live entries, rounded up to a power of two + * via {@link #sizeFor(int)}. + */ + public static int capacityFor(int cardinalityLimit, float loadFactor) { + if (!(loadFactor > 0f && loadFactor < 1f)) { + throw new IllegalArgumentException("loadFactor must be in (0, 1): " + loadFactor); + } + return sizeFor((int) (cardinalityLimit / loadFactor)); } /** @@ -660,6 +709,54 @@ public static void insertHeadEntryFor( 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 + * sizeTracker} first, splicing {@code entry} in only if the reservation succeeds. Returns {@code + * false} (without touching {@code buckets}) once {@code sizeTracker} is at capacity. Lets a + * composer working directly against the static building blocks (e.g. {@link D1#insert}, or a + * caller-owned table like client-side stats' {@code AggregateTable}) get the same one-call + * insert-with-cap-check contract that {@link D1}/{@link D2} give their own callers. + */ + public static boolean insertHeadEntryFor( + @Nonnull Hashtable.Entry[] buckets, + long keyHash, + @Nonnull Hashtable.Entry entry, + @Nonnull SizeTracker sizeTracker) { + if (!sizeTracker.tryReserve()) { + return false; + } + insertHeadEntryFor(buckets, keyHash, entry); + return true; + } + + /** + * Scans the bucket chain at {@code keyHash} for the first entry matching {@code matches}, unlinks + * it, decrements {@code sizeTracker}, and returns it -- or returns {@code null} (leaving {@code + * buckets} and {@code sizeTracker} untouched) if nothing in the chain matches. Mirrors {@link + * #insertHeadEntryFor(Hashtable.Entry[], long, Hashtable.Entry, SizeTracker)} on the removal + * side: the one-call, size-tracked shape that {@link D1#remove} and {@link D2#remove} delegate + * to, so a composer driving the static building blocks directly gets the same bookkeeping without + * hand-rolling the mutating-iterator loop. + */ + @Nullable + public static TEntry removeMatching( + @Nonnull Hashtable.Entry[] buckets, + long keyHash, + @Nonnull Predicate matches, + @Nonnull SizeTracker sizeTracker) { + for (MutatingBucketIterator iter = mutatingBucketIterator(buckets, keyHash); + iter.hasNext(); ) { + TEntry curEntry = iter.next(); + if (matches.test(curEntry)) { + iter.remove(); + sizeTracker.decrement(); + return curEntry; + } + } + return null; + } + public static void clear(@Nonnull Hashtable.Entry[] buckets) { Arrays.fill(buckets, null); } @@ -930,16 +1027,16 @@ private Table(Hashtable.Entry[] buckets, int capacity) { * EvictionCursor}. */ @Nonnull - public static Table createTable(int capacity) { - Hashtable.Entry[] buckets = new Hashtable.Entry[sizeFor((int) (capacity * 4 / 3f))]; - return new Table(buckets, capacity); + public static Table createCappedTable(int maxCapacity) { + Hashtable.Entry[] buckets = create(capacityFor(maxCapacity)); + return new Table(buckets, maxCapacity); } /** * Deprecated facade over the static building blocks that are now methods on {@link Hashtable} - * itself (mirroring the concurrent variant). Each method here delegates to its {@code - * Hashtable.*} counterpart; the two sizing helpers with no blessed equivalent -- {@link - * #create(int, float)} and {@link #MAX_RATIO} -- keep their real bodies here. + * itself (mirroring the concurrent variant). 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 (e.g. client-side statistics). * New code should call the {@code Hashtable.*} statics directly. @@ -951,12 +1048,13 @@ public static final class Support { private Support() {} /** - * @deprecated use {@link Hashtable#createFixedBuckets(Class, int)}. + * @deprecated use {@link Hashtable#create(int)} (or {@link Hashtable#create(Class, int)} for a + * typed spine). */ @Deprecated @Nonnull public static Hashtable.Entry[] create(int requestedSize) { - return new Entry[sizeFor(requestedSize)]; + return Hashtable.create(requestedSize); } /** @@ -970,23 +1068,28 @@ public static Hashtable.Entry[] create(int requestedSize) { * 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}). * - *

No blessed equivalent: callers wanting load-factor headroom size the capacity themselves - * and call {@link Hashtable#createFixedBuckets(Class, int)}. - * - * @deprecated size the capacity yourself and use {@link Hashtable#createFixedBuckets(Class, - * int)}. + * @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) { - return new Entry[sizeFor((int) (requestedSize * 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 = 4.0f / 3.0f; + @Deprecated public static final float MAX_RATIO = 1.0f / Hashtable.DEFAULT_LOAD_FACTOR; /** * @deprecated use {@link Hashtable#sizeFor(int)}. @@ -1121,7 +1224,7 @@ public static final class BucketIterator implements Iterat 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(); } @@ -1186,7 +1289,7 @@ public static final class MutatingBucketIterator 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; @@ -1284,7 +1387,7 @@ public void replace(@Nonnull TEntry replacementEntry) { 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); } 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 abd802182f2..df17f06b9f8 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -19,14 +19,14 @@ class HashtableD1Test { @Test void emptyTableLookupReturnsNull() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); assertNull(table.get("missing")); assertEquals(0, table.size()); } @Test void insertedEntryIsRetrievable() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); StringIntEntry e = new StringIntEntry("foo", 1); table.insert(e); assertEquals(1, table.size()); @@ -41,7 +41,8 @@ void keyExposesTheConstructionKey() { @Test void multipleInsertsRetrievableSeparately() { - Hashtable.D1 table = new Hashtable.D1<>(16); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 16); StringIntEntry a = new StringIntEntry("alpha", 1); StringIntEntry b = new StringIntEntry("beta", 2); StringIntEntry c = new StringIntEntry("gamma", 3); @@ -56,7 +57,7 @@ void multipleInsertsRetrievableSeparately() { @Test void inPlaceMutationVisibleViaSubsequentGet() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("counter", 0)); for (int i = 0; i < 10; i++) { StringIntEntry e = table.get("counter"); @@ -67,7 +68,7 @@ void inPlaceMutationVisibleViaSubsequentGet() { @Test void removeUnlinksEntryAndDecrementsSize() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); assertEquals(2, table.size()); @@ -82,7 +83,7 @@ void removeUnlinksEntryAndDecrementsSize() { @Test void removeNonexistentReturnsNullAndDoesNotChangeSize() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); assertNull(table.remove("nope")); assertEquals(1, table.size()); @@ -90,7 +91,7 @@ void removeNonexistentReturnsNullAndDoesNotChangeSize() { @Test void insertOrReplaceReturnsPriorEntryOrNullOnInsert() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); StringIntEntry first = new StringIntEntry("k", 1); assertNull(table.insertOrReplace(first), "fresh insert returns null"); assertEquals(1, table.size()); @@ -103,7 +104,7 @@ void insertOrReplaceReturnsPriorEntryOrNullOnInsert() { @Test void clearEmptiesTheTable() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); table.clear(); @@ -116,7 +117,7 @@ void clearEmptiesTheTable() { @Test void forEachVisitsEveryInsertedEntry() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); table.insert(new StringIntEntry("c", 3)); @@ -130,7 +131,7 @@ void forEachVisitsEveryInsertedEntry() { @Test void forEachWithContextPassesContextToConsumer() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 10)); table.insert(new StringIntEntry("b", 20)); table.insert(new StringIntEntry("c", 30)); @@ -144,7 +145,7 @@ void forEachWithContextPassesContextToConsumer() { @Test void forEachWithContextOnEmptyTableDoesNothing() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); Map seen = new HashMap<>(); table.forEach(seen, (ctx, e) -> ctx.put(e.key, e.value)); assertEquals(0, seen.size()); @@ -152,7 +153,7 @@ void forEachWithContextOnEmptyTableDoesNothing() { @Test void nullKeyIsPermittedAndDistinctFromAbsent() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); assertNull(table.get(null)); StringIntEntry nullKeyed = new StringIntEntry(null, 7); table.insert(nullKeyed); @@ -166,7 +167,8 @@ void nullKeyIsPermittedAndDistinctFromAbsent() { void hashCollisionsResolveByEquality() { // Force two distinct keys with the same hashCode -- the chain must still distinguish them // via matches(). - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKeyEntry e1 = new CollidingKeyEntry(k1, 100); @@ -180,7 +182,8 @@ void hashCollisionsResolveByEquality() { @Test void hashCollisionsThenRemoveLeavesOtherIntact() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -196,7 +199,7 @@ void hashCollisionsThenRemoveLeavesOtherIntact() { @Test void getOrCreateOnMissBuildsEntryViaCreator() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); int[] createCount = {0}; StringIntEntry created = table.getOrCreate( @@ -215,7 +218,7 @@ void getOrCreateOnMissBuildsEntryViaCreator() { @Test void getOrCreateOnHitSkipsCreator() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); StringIntEntry seeded = new StringIntEntry("foo", 1); table.insert(seeded); int[] createCount = {0}; @@ -233,7 +236,7 @@ void getOrCreateOnHitSkipsCreator() { @Test void getOrCreateNullKeyIsPermitted() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); StringIntEntry created = table.getOrCreate(null, k -> new StringIntEntry(k, 7)); assertNotNull(created); assertNull(created.key); @@ -244,7 +247,7 @@ void getOrCreateNullKeyIsPermitted() { @Test void insertReturnsFalseOnceAtCapacity() { - Hashtable.D1 table = new Hashtable.D1<>(2); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); assertTrue(table.insert(new StringIntEntry("a", 1))); assertTrue(table.insert(new StringIntEntry("b", 2))); assertFalse(table.insert(new StringIntEntry("c", 3))); @@ -254,7 +257,7 @@ void insertReturnsFalseOnceAtCapacity() { @Test void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { - Hashtable.D1 table = new Hashtable.D1<>(2); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); @@ -267,7 +270,7 @@ void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { @Test void insertOrReplaceStillReplacesAtCapacityButThrowsOnFreshInsert() { - Hashtable.D1 table = new Hashtable.D1<>(2); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); @@ -284,7 +287,7 @@ void insertOrReplaceStillReplacesAtCapacityButThrowsOnFreshInsert() { @Test void isFullReflectsCapacity() { - Hashtable.D1 table = new Hashtable.D1<>(2); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); assertFalse(table.isFull()); table.insert(new StringIntEntry("a", 1)); assertFalse(table.isFull()); @@ -296,7 +299,7 @@ void isFullReflectsCapacity() { @Test void drainVisitsEveryEntryThenEmptiesTable() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); Map drained = new HashMap<>(); @@ -318,7 +321,7 @@ void drainVisitsEveryEntryThenEmptiesTable() { @Test void drainWithContextPassesContextToSink() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); Map drained = new HashMap<>(); @@ -331,7 +334,7 @@ void drainWithContextPassesContextToSink() { @Test void drainOnEmptyTableDoesNothing() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); Map drained = new HashMap<>(); table.drain(e -> drained.put(e.key, e.value)); assertEquals(0, drained.size()); 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 566e603da4a..fb0f6596b86 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java @@ -16,7 +16,7 @@ class HashtableD2Test { @Test void pairKeysParticipateInIdentity() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); PairEntry ab = new PairEntry("a", 1, 100); PairEntry ac = new PairEntry("a", 2, 200); PairEntry bb = new PairEntry("b", 1, 300); @@ -32,7 +32,7 @@ void pairKeysParticipateInIdentity() { @Test void removePairUnlinks() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); PairEntry ab = new PairEntry("a", 1, 100); PairEntry ac = new PairEntry("a", 2, 200); table.insert(ab); @@ -45,7 +45,7 @@ void removePairUnlinks() { @Test void insertOrReplaceMatchesOnBothKeys() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); PairEntry first = new PairEntry("k", 7, 1); assertNull(table.insertOrReplace(first)); PairEntry second = new PairEntry("k", 7, 2); @@ -58,7 +58,7 @@ void insertOrReplaceMatchesOnBothKeys() { @Test void forEachVisitsBothPairs() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); Set seen = new HashSet<>(); @@ -70,7 +70,7 @@ void forEachVisitsBothPairs() { @Test void forEachWithContextPassesContextToConsumer() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); Set seen = new HashSet<>(); @@ -82,7 +82,7 @@ void forEachWithContextPassesContextToConsumer() { @Test void getOrCreateOnMissBuildsEntryViaCreator() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); int[] createCount = {0}; PairEntry created = table.getOrCreate( @@ -103,7 +103,7 @@ void getOrCreateOnMissBuildsEntryViaCreator() { @Test void getOrCreateOnHitSkipsCreator() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); PairEntry seeded = new PairEntry("a", 1, 100); table.insert(seeded); int[] createCount = {0}; @@ -161,7 +161,7 @@ void entryHashDiffersForDifferentKeys() { @Test void removeReturnsNullForMissingKey() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 100)); assertNull(table.remove("a", 2)); @@ -171,7 +171,7 @@ void removeReturnsNullForMissingKey() { @Test void clearEmptiesTable() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); assertEquals(2, table.size()); @@ -185,7 +185,7 @@ void clearEmptiesTable() { @Test void insertReturnsFalseOnceAtCapacity() { - Hashtable.D2 table = new Hashtable.D2<>(2); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); assertTrue(table.insert(new PairEntry("a", 1, 100))); assertTrue(table.insert(new PairEntry("b", 2, 200))); assertFalse(table.insert(new PairEntry("c", 3, 300))); @@ -195,7 +195,7 @@ void insertReturnsFalseOnceAtCapacity() { @Test void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { - Hashtable.D2 table = new Hashtable.D2<>(2); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); @@ -208,7 +208,7 @@ void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { @Test void insertOrReplaceStillReplacesAtCapacityButThrowsOnFreshInsert() { - Hashtable.D2 table = new Hashtable.D2<>(2); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); @@ -225,7 +225,7 @@ void insertOrReplaceStillReplacesAtCapacityButThrowsOnFreshInsert() { @Test void isFullReflectsCapacity() { - Hashtable.D2 table = new Hashtable.D2<>(2); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); assertFalse(table.isFull()); table.insert(new PairEntry("a", 1, 100)); assertFalse(table.isFull()); @@ -237,7 +237,7 @@ void isFullReflectsCapacity() { @Test void drainVisitsEveryEntryThenEmptiesTable() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); Set drained = new HashSet<>(); @@ -254,7 +254,7 @@ void drainVisitsEveryEntryThenEmptiesTable() { @Test void drainWithContextPassesContextToSink() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); Set drained = new HashSet<>(); @@ -267,7 +267,7 @@ void drainWithContextPassesContextToSink() { @Test void drainOnEmptyTableDoesNothing() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); Set drained = new HashSet<>(); table.drain(e -> drained.add(e.key1 + ":" + e.key2)); assertEquals(0, drained.size()); 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 e4cec857bd7..7b378f64df9 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -32,7 +32,7 @@ class StaticBuildingBlockTests { 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 = Hashtable.createFixedBuckets(StringIntEntry.class, 5); + Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 5); // Length must be a power of two >= 5 int len = buckets.length; assertTrue(len >= 5); @@ -66,9 +66,37 @@ void sizeForRejectsNegativeCapacity() { assertThrows(IllegalArgumentException.class, () -> Hashtable.sizeFor(Integer.MIN_VALUE)); } + @Test + void capacityForAppliesDefaultLoadFactorHeadroom() { + // 12 / 0.75 = 16 -> already a power of two. + assertEquals(16, Hashtable.capacityFor(12)); + // 5 / 0.75 = 6.67 -> truncated to 6 -> sizeFor rounds up to 8. + assertEquals(8, Hashtable.capacityFor(5)); + } + + @Test + void capacityForMatchesDefaultLoadFactorConstant() { + assertEquals(0.75f, Hashtable.DEFAULT_LOAD_FACTOR); + assertEquals( + Hashtable.capacityFor(20), Hashtable.capacityFor(20, Hashtable.DEFAULT_LOAD_FACTOR)); + } + + @Test + void capacityForAtExplicitLoadFactor() { + // 10 / 0.5 = 20 -> sizeFor rounds up to 32. + assertEquals(32, Hashtable.capacityFor(10, 0.5f)); + } + + @Test + void capacityForRejectsLoadFactorOutOfRange() { + assertThrows(IllegalArgumentException.class, () -> Hashtable.capacityFor(10, 0f)); + assertThrows(IllegalArgumentException.class, () -> Hashtable.capacityFor(10, 1f)); + assertThrows(IllegalArgumentException.class, () -> Hashtable.capacityFor(10, -0.5f)); + } + @Test void bucketIndexIsBoundedByArrayLength() { - Hashtable.Entry[] buckets = Hashtable.createFixedBuckets(StringIntEntry.class, 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 = Hashtable.bucketIndex(buckets, h); assertTrue(idx >= 0 && idx < buckets.length, "bucketIndex out of range for hash " + h); @@ -77,7 +105,7 @@ void bucketIndexIsBoundedByArrayLength() { @Test void clearNullsAllBuckets() { - Hashtable.Entry[] buckets = Hashtable.createFixedBuckets(StringIntEntry.class, 4); + Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); buckets[0] = new StringIntEntry("x", 1); buckets[1] = new StringIntEntry("y", 2); Hashtable.clear(buckets); @@ -88,7 +116,7 @@ void clearNullsAllBuckets() { @Test void drainVisitsEveryEntryThenClears() { - Hashtable.Entry[] buckets = Hashtable.createFixedBuckets(StringIntEntry.class, 4); + Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); buckets[0] = new StringIntEntry("x", 1); buckets[1] = new StringIntEntry("y", 2); Set drained = new HashSet<>(); @@ -103,7 +131,7 @@ void drainVisitsEveryEntryThenClears() { @Test void insertHeadEntrySplicesAsNewHead() { - Hashtable.Entry[] buckets = Hashtable.createFixedBuckets(StringIntEntry.class, 4); + Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); StringIntEntry a = new StringIntEntry("a", 1); StringIntEntry b = new StringIntEntry("b", 2); Hashtable.insertHeadEntryAt(buckets, 0, a); @@ -143,6 +171,115 @@ void createWithScaleRoundsUpToPowerOfTwo() { Hashtable.Entry[] buckets = Support.create(7, 1.5f); assertEquals(16, buckets.length); } + + @Test + 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); + buckets[0] = new StringIntEntry("a", 1); + Support.clear(buckets); + for (Hashtable.Entry b : buckets) { + assertNull(b); + } + } + + @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()); + } } // ============ BucketIterator ============ @@ -155,7 +292,8 @@ void walksOnlyMatchingHash() { // Build a bucket array with two entries that share a bucket but have different hashes. // Use Hashtable.D1 to seed; then call Hashtable.bucketIterator directly with the matching // hash and verify it only returns the matching entry. - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -174,7 +312,8 @@ void walksOnlyMatchingHash() { @Test void exhaustedIteratorThrowsNoSuchElement() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 4); table.insert(new StringIntEntry("only", 1)); long h = Hashtable.D1.Entry.hash("only"); BucketIterator it = Hashtable.bucketIterator(table.buckets, h); @@ -192,7 +331,8 @@ class MutatingBucketIteratorTests { @Test void removeFromHeadOfChainUnlinks() { // Make three entries with the same hash so they chain in one bucket - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -225,7 +365,8 @@ void removeFromHeadOfChainUnlinks() { @Test void replaceSwapsEntryAndPreservesChain() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKeyEntry e1 = new CollidingKeyEntry(k1, 1); @@ -247,7 +388,8 @@ void replaceSwapsEntryAndPreservesChain() { @Test void removeWithoutNextThrows() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 4); table.insert(new StringIntEntry("a", 1)); MutatingBucketIterator it = Hashtable.mutatingBucketIterator(table.buckets, Hashtable.D1.Entry.hash("a")); @@ -262,7 +404,8 @@ class MutatingTableIteratorTests { @Test void walksEveryEntryAcrossBuckets() { - Hashtable.D1 table = new Hashtable.D1<>(16); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 16); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); table.insert(new StringIntEntry("c", 3)); @@ -281,7 +424,8 @@ void walksEveryEntryAcrossBuckets() { @Test void emptyTableIteratorIsExhausted() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 8); MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); assertFalse(it.hasNext()); assertThrows(NoSuchElementException.class, it::next); @@ -289,7 +433,8 @@ void emptyTableIteratorIsExhausted() { @Test void removeUnlinksBucketHead() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); table.insert(new CollidingKeyEntry(k1, 1)); @@ -308,7 +453,8 @@ void removeUnlinksBucketHead() { @Test void removeUnlinksMidChainEntry() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -343,7 +489,8 @@ void removeSkipsOverEmptyBuckets() { // Three distinct keys that land in different buckets (low entry count vs large bucket array // makes empty buckets between them very likely). Verify the iterator skips empties cleanly // after a remove. - Hashtable.D1 table = new Hashtable.D1<>(64); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 64); table.insert(new StringIntEntry("alpha", 1)); table.insert(new StringIntEntry("beta", 2)); table.insert(new StringIntEntry("gamma", 3)); @@ -361,7 +508,8 @@ void removeSkipsOverEmptyBuckets() { @Test void removeWithoutNextThrows() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 4); table.insert(new StringIntEntry("a", 1)); MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); assertThrows(IllegalStateException.class, it::remove); @@ -369,7 +517,8 @@ void removeWithoutNextThrows() { @Test void removeTwiceWithoutInterveningNextThrows() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 4); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); @@ -383,7 +532,8 @@ void halfOpenRangeOmitsBucketsOutsideTheRange() { // CollidingKey lets us pin entries to specific buckets via controlled hashCode. 16-slot // table -> bucketIndex = hash & 15. Place entries in buckets 0, 5, and 10; iterate // [5, 10) -- should see only bucket 5. - Hashtable.D1 table = new Hashtable.D1<>(16); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 16); table.insert(new CollidingKeyEntry(new CollidingKey("b0", 0), 1)); table.insert(new CollidingKeyEntry(new CollidingKey("b5", 5), 2)); table.insert(new CollidingKeyEntry(new CollidingKey("b10", 10), 3)); @@ -402,7 +552,8 @@ void halfOpenRangeOmitsBucketsOutsideTheRange() { void emptyHalfOpenRangeIsExhausted() { // start == end -> immediately-exhausted iterator. Important: this is the wrap-around // pass [0, cursor) when cursor == 0 in resumable sweeps. - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets, 0, 0); @@ -411,7 +562,8 @@ void emptyHalfOpenRangeIsExhausted() { @Test void rangeBoundsOutOfOrderThrows() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 8); assertThrows( IndexOutOfBoundsException.class, () -> Hashtable.mutatingTableIterator(table.buckets, -1, 4)); @@ -429,7 +581,8 @@ void rangeBoundsOutOfOrderThrows() { void currentBucketReportsLandingIndex() { // Pin one entry to a known bucket and check currentBucket() after next() reports that // bucket. Before any next() (or after remove()), currentBucket() returns -1. - Hashtable.D1 table = new Hashtable.D1<>(16); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 16); table.insert(new CollidingKeyEntry(new CollidingKey("b3", 3), 1)); MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); @@ -438,4 +591,131 @@ void currentBucketReportsLandingIndex() { assertEquals(3, it.currentBucket(), "currentBucket should report the entry's bucket"); } } + + // ============ EvictionCursor ============ + + @Nested + class EvictionCursorTests { + + @Test + void evictOneRemovesFirstMatchAndAdvancesCursor() { + Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); + buckets[0] = new StringIntEntry("a", 1); + buckets[1] = new StringIntEntry("b", 2); + Hashtable.EvictionCursor cursor = new Hashtable.EvictionCursor(); + + StringIntEntry evicted = + (StringIntEntry) cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 2); + + assertEquals("b", evicted.key); + assertNull(buckets[1]); + assertNotNull(buckets[0]); + } + + @Test + void evictOneReturnsNullWhenNothingMatches() { + Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); + buckets[0] = new StringIntEntry("a", 1); + Hashtable.EvictionCursor cursor = new Hashtable.EvictionCursor(); + + assertNull(cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 999)); + assertNotNull(buckets[0]); + } + + @Test + void evictOneWrapsAroundToStartOfTable() { + Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); + buckets[0] = new StringIntEntry("a", 1); + buckets[3] = new StringIntEntry("d", 4); + Hashtable.EvictionCursor cursor = new Hashtable.EvictionCursor(); + + // First eviction matches bucket 3, advancing the cursor there. + StringIntEntry first = + (StringIntEntry) cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 4); + assertEquals("d", first.key); + + // Only remaining candidate is bucket 0, before the cursor -- requires wrap-around. + StringIntEntry second = + (StringIntEntry) cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 1); + assertEquals("a", second.key); + } + + @Test + void drainRemovesAllMatchesAndResetsCursor() { + Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); + buckets[0] = new StringIntEntry("a", 1); + buckets[1] = new StringIntEntry("b", 2); + buckets[2] = new StringIntEntry("c", 3); + Hashtable.EvictionCursor cursor = new Hashtable.EvictionCursor(); + cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 3); + + int removed = cursor.drain(buckets, e -> ((StringIntEntry) 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 = + (StringIntEntry) cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 1); + assertEquals("a2", evicted.key); + } + + @Test + void resetZeroesCursor() { + Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); + buckets[3] = new StringIntEntry("d", 4); + Hashtable.EvictionCursor cursor = new Hashtable.EvictionCursor(); + cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 4); + + cursor.reset(); + + buckets[0] = new StringIntEntry("a", 1); + StringIntEntry evicted = + (StringIntEntry) cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 1); + assertEquals("a", evicted.key); + } + } + + // ============ Table ============ + + @Nested + class TableTests { + + @Test + void createTableSizesBucketsWithHeadroomAndCapsSize() { + Hashtable.Table table = Hashtable.createCappedTable(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.size); + assertNotNull(table.evictionCursor); + assertEquals(4, table.size.capacity()); + assertFalse(table.size.isFull()); + } + + @Test + void tableSizeTrackerRespectsCapacity() { + Hashtable.Table table = Hashtable.createCappedTable(1); + + assertTrue(table.size.tryReserve()); + assertTrue(table.size.isFull()); + assertFalse(table.size.tryReserve()); + } + + @Test + void tableEvictionCursorOperatesOnItsOwnBuckets() { + Hashtable.Table table = Hashtable.createCappedTable(4); + table.buckets[0] = new StringIntEntry("a", 1); + + StringIntEntry evicted = + (StringIntEntry) + table.evictionCursor.evictOne(table.buckets, e -> ((StringIntEntry) e).value == 1); + + assertEquals("a", evicted.key); + assertNull(table.buckets[0]); + } + } } From c2b9acfe30774979e37edc0af87074ad50c87f6c Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 16:03:44 -0400 Subject: [PATCH 13/41] Avoid a capturing predicate in Hashtable D1/D2 remove remove() delegated to removeMatching with `e -> e.matches(key)`, which captures `key` and so allocates a fresh Predicate on every call -- LambdaMetafactory can only cache non-capturing lambdas. insertOrReplace sits directly below it and walks the same chain with no lambda at all. Escape analysis often erases this, and remove() has no production caller today, so the argument is consistency rather than measured throughput: this class ships context-passing forEach/drain overloads specifically so callers can avoid capturing lambdas, and then captured one itself. removeMatching stays as a building block for composers that match on something other than the key, so it and the size-tracked insertHeadEntryFor get direct tests now that D1/D2 no longer cover them by delegation. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/datadog/trace/util/Hashtable.java | 29 ++++++++++- .../datadog/trace/util/HashtableTest.java | 52 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) 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 e9b1aa60313..1ee8f1ca088 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -206,8 +206,22 @@ public TEntry get(@Nullable 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 + // insertOrReplace below. long keyHash = D1.Entry.hash(key); - return removeMatching(this.buckets, keyHash, e -> e.matches(key), this.sizeTracker); + for (MutatingBucketIterator iter = mutatingBucketIterator(this.buckets, keyHash); + iter.hasNext(); ) { + TEntry curEntry = iter.next(); + if (curEntry.matches(key)) { + iter.remove(); + this.sizeTracker.decrement(); + return curEntry; + } + } + return null; } /** @@ -445,8 +459,19 @@ public TEntry get(@Nullable K1 key1, @Nullable 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); - return removeMatching(this.buckets, keyHash, e -> e.matches(key1, key2), this.sizeTracker); + for (MutatingBucketIterator iter = mutatingBucketIterator(this.buckets, keyHash); + iter.hasNext(); ) { + TEntry curEntry = iter.next(); + if (curEntry.matches(key1, key2)) { + iter.remove(); + this.sizeTracker.decrement(); + return curEntry; + } + } + return null; } /** Two-key analogue of {@link D1#insert}, with the same strict-cap refusal contract. */ 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 7b378f64df9..939eb607856 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -94,6 +94,58 @@ void capacityForRejectsLoadFactorOutOfRange() { 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.SizeTracker size = new Hashtable.SizeTracker(2); + + StringIntEntry a = new StringIntEntry("a", 1); + StringIntEntry b = new StringIntEntry("b", 2); + StringIntEntry c = new StringIntEntry("c", 3); + + assertTrue(Hashtable.insertHeadEntryFor(buckets, a.keyHash, a, size)); + assertTrue(Hashtable.insertHeadEntryFor(buckets, b.keyHash, b, size)); + assertEquals(2, size.size()); + + assertFalse( + Hashtable.insertHeadEntryFor(buckets, c.keyHash, c, size), + "refused once the tracker is at capacity"); + assertEquals(2, size.size(), "a refused insert must not consume a slot"); + } + + @Test + void removeMatchingUnlinksAndDecrements() { + Hashtable.Entry[] buckets = Hashtable.create(8); + Hashtable.SizeTracker size = new Hashtable.SizeTracker(8); + StringIntEntry a = new StringIntEntry("a", 1); + Hashtable.insertHeadEntryFor(buckets, a.keyHash, a, size); + + StringIntEntry removed = + Hashtable.removeMatching(buckets, a.keyHash, e -> e.matches("a"), size); + + assertSame(a, removed); + assertEquals(0, size.size()); + assertNull(Hashtable.bucketFor(buckets, a.keyHash)); + } + + @Test + void removeMatchingReturnsNullAndLeavesStateWhenNothingMatches() { + Hashtable.Entry[] buckets = Hashtable.create(8); + Hashtable.SizeTracker size = new Hashtable.SizeTracker(8); + StringIntEntry a = new StringIntEntry("a", 1); + Hashtable.insertHeadEntryFor(buckets, a.keyHash, a, size); + + assertNull( + Hashtable.removeMatching( + buckets, a.keyHash, e -> e.matches("nope"), size)); + assertEquals(1, size.size(), "a non-matching scan must not decrement"); + assertSame(a, Hashtable.bucketFor(buckets, a.keyHash)); + } + @Test void bucketIndexIsBoundedByArrayLength() { Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 16); From 3f4c479afcbb024363a0a4ac8bf7334205123a53 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 16:31:06 -0400 Subject: [PATCH 14/41] Lead the size-tracked Hashtable statics with the SizeTracker Parameter order for the size-tracked static building blocks is now: mutated bookkeeping, then the spine, then the key, then callbacks. insertHeadEntryFor(sizeTracker, buckets, keyHash, entry) removeMatching(sizeTracker, buckets, keyHash, matches) Appending the tracker made the tracked and untracked forms differ only in a trailing argument, which is the wrong shape for a distinction that fails silently: a missed increment refuses inserts early and gets noticed, while a missed decrement leaks the cap until the table stops accepting anything. Leading with it puts the difference at the head of the call, where it is visible while reading and greppable in review. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/datadog/trace/util/Hashtable.java | 27 ++++++++++++------- .../datadog/trace/util/HashtableTest.java | 14 +++++----- 2 files changed, 24 insertions(+), 17 deletions(-) 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 1ee8f1ca088..7975afb5c19 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -230,7 +230,7 @@ public TEntry remove(@Nullable K key) { * shadowed behind the existing entry. */ public boolean insert(@Nonnull TEntry newEntry) { - return insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry, this.sizeTracker); + return insertHeadEntryFor(this.sizeTracker, this.buckets, newEntry.keyHash, newEntry); } /** @@ -476,7 +476,7 @@ public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { /** Two-key analogue of {@link D1#insert}, with the same strict-cap refusal contract. */ public boolean insert(@Nonnull TEntry newEntry) { - return insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry, this.sizeTracker); + return insertHeadEntryFor(this.sizeTracker, this.buckets, newEntry.keyHash, newEntry); } /** Two-key analogue of {@link D1#insertOrReplace}, with the same refusal contract. */ @@ -742,12 +742,18 @@ public static void insertHeadEntryFor( * composer working directly against the static building blocks (e.g. {@link D1#insert}, or a * caller-owned table like client-side stats' {@code AggregateTable}) get the same one-call * insert-with-cap-check contract that {@link D1}/{@link D2} give their own callers. + * + *

{@code sizeTracker} 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 SizeTracker sizeTracker, @Nonnull Hashtable.Entry[] buckets, long keyHash, - @Nonnull Hashtable.Entry entry, - @Nonnull SizeTracker sizeTracker) { + @Nonnull Hashtable.Entry entry) { if (!sizeTracker.tryReserve()) { return false; } @@ -759,17 +765,18 @@ public static boolean insertHeadEntryFor( * Scans the bucket chain at {@code keyHash} for the first entry matching {@code matches}, unlinks * it, decrements {@code sizeTracker}, and returns it -- or returns {@code null} (leaving {@code * buckets} and {@code sizeTracker} untouched) if nothing in the chain matches. Mirrors {@link - * #insertHeadEntryFor(Hashtable.Entry[], long, Hashtable.Entry, SizeTracker)} on the removal - * side: the one-call, size-tracked shape that {@link D1#remove} and {@link D2#remove} delegate - * to, so a composer driving the static building blocks directly gets the same bookkeeping without - * hand-rolling the mutating-iterator loop. + * #insertHeadEntryFor(SizeTracker, 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 sizeTracker} leads for the same reason it does on the insert side. */ @Nullable public static TEntry removeMatching( + @Nonnull SizeTracker sizeTracker, @Nonnull Hashtable.Entry[] buckets, long keyHash, - @Nonnull Predicate matches, - @Nonnull SizeTracker sizeTracker) { + @Nonnull Predicate matches) { for (MutatingBucketIterator iter = mutatingBucketIterator(buckets, keyHash); iter.hasNext(); ) { TEntry curEntry = iter.next(); 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 939eb607856..154093ba684 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -107,12 +107,12 @@ void insertHeadEntryForTracksSizeAndRefusesAtCapacity() { StringIntEntry b = new StringIntEntry("b", 2); StringIntEntry c = new StringIntEntry("c", 3); - assertTrue(Hashtable.insertHeadEntryFor(buckets, a.keyHash, a, size)); - assertTrue(Hashtable.insertHeadEntryFor(buckets, b.keyHash, b, size)); + assertTrue(Hashtable.insertHeadEntryFor(size, buckets, a.keyHash, a)); + assertTrue(Hashtable.insertHeadEntryFor(size, buckets, b.keyHash, b)); assertEquals(2, size.size()); assertFalse( - Hashtable.insertHeadEntryFor(buckets, c.keyHash, c, size), + Hashtable.insertHeadEntryFor(size, buckets, c.keyHash, c), "refused once the tracker is at capacity"); assertEquals(2, size.size(), "a refused insert must not consume a slot"); } @@ -122,10 +122,10 @@ void removeMatchingUnlinksAndDecrements() { Hashtable.Entry[] buckets = Hashtable.create(8); Hashtable.SizeTracker size = new Hashtable.SizeTracker(8); StringIntEntry a = new StringIntEntry("a", 1); - Hashtable.insertHeadEntryFor(buckets, a.keyHash, a, size); + Hashtable.insertHeadEntryFor(size, buckets, a.keyHash, a); StringIntEntry removed = - Hashtable.removeMatching(buckets, a.keyHash, e -> e.matches("a"), size); + Hashtable.removeMatching(size, buckets, a.keyHash, e -> e.matches("a")); assertSame(a, removed); assertEquals(0, size.size()); @@ -137,11 +137,11 @@ void removeMatchingReturnsNullAndLeavesStateWhenNothingMatches() { Hashtable.Entry[] buckets = Hashtable.create(8); Hashtable.SizeTracker size = new Hashtable.SizeTracker(8); StringIntEntry a = new StringIntEntry("a", 1); - Hashtable.insertHeadEntryFor(buckets, a.keyHash, a, size); + Hashtable.insertHeadEntryFor(size, buckets, a.keyHash, a); assertNull( Hashtable.removeMatching( - buckets, a.keyHash, e -> e.matches("nope"), size)); + size, buckets, a.keyHash, e -> e.matches("nope"))); assertEquals(1, size.size(), "a non-matching scan must not decrement"); assertSame(a, Hashtable.bucketFor(buckets, a.keyHash)); } From c630240548035cf3de33162534e5d20d2c360aef Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 17:00:03 -0400 Subject: [PATCH 15/41] Drop references to the deprecated Support facade from Hashtable javadoc Nothing outside Support mentions it now: the class javadoc no longer advertises the facade, D1's "roll your own eviction" pointer aims at createCappedTable/SizeTracker/EvictionCursor instead, and the historical note on the static-building-block section is gone. Support keeps its own @deprecated pointers saying what replaced each member -- that direction is the useful one. Since no production code references the facade any more, it can be deleted outright once client-side stats migrates. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/main/java/datadog/trace/util/Hashtable.java | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) 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 7975afb5c19..fdc187aeadb 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -33,8 +33,7 @@ * {@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). The deprecated {@link Support} class is a thin facade over those - * same statics, retained for source compatibility. + * Hashtable.Entry)}, and friends). */ public final class Hashtable { private Hashtable() {} @@ -82,8 +81,10 @@ public final TEntry next() { * capacity, {@link #insert} returns {@code false} and {@link #getOrCreate} 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 - * {@link Hashtable.Support} and manage the bucket array yourself. Actual bucket-array length is - * rounded up to the next power of two. + * the static building blocks and drive the bucket array yourself -- {@link + * Hashtable#createCappedTable(int)} hands you a spine, a {@link SizeTracker}, and an {@link + * EvictionCursor} already matched to each other. 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}. @@ -579,10 +580,6 @@ public void drain(C context, @Nonnull BiConsumer // // Not thread-safe: there is no locking here. Concurrent access, including mixing reads with // writes, requires external synchronization. - // - // These were previously nested under the Support class; that class is now a deprecated facade - // delegating here (retained for source compatibility with existing callers such as client-side - // statistics). // ============================================================================================ /** Upper bound on the bucket-array length returned by {@link #sizeFor(int)}. */ From 69ae56fe36f03b27bbdd1e76a12354305f713cba Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 17:09:44 -0400 Subject: [PATCH 16/41] Lead getOrCreate's javadoc with the fact that it can refuse The nullable return was buried in the third paragraph, behind the hash-reuse and creator-contract notes, while the method name reads as total. That ordering is how the @Nonnull annotation got there in the first place. Both D1 and D2 now state up front that a create can be refused at capacity, that a hit is still always returned, and that isFull() answers the question ahead of time. Also notes that refusal is a designed steady state for a capped table rather than an exceptional one, so callers should decide deliberately what a refused create does instead of letting the null fall through. Keeping the name getOrCreate rather than tryGetOrCreate: the posture is per-instance (capped vs uncapped) while the method name is per-class, so a try- prefix would over-promise failure on an uncapped table exactly as the current name under-promises it on a capped one. FlatHashtable already made this call explicitly -- the factory name carries the posture -- and ConcurrentHashtable's getOrCreate is correctly @Nonnull because it never refuses. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/datadog/trace/util/Hashtable.java | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) 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 fdc187aeadb..8c087f16548 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -264,17 +264,25 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { } /** - * Returns the entry for {@code key}, building one via {@code creator} if absent. Computes the - * hash once and reuses it for both the lookup and (on miss) the insert -- avoids the - * double-hash that "{@code get}; if null then {@code insert}" would incur. + * Returns the entry for {@code key}, building one via {@code creator} if absent -- or {@code + * null} if the key is absent and the table is at capacity. This method can refuse: + * despite the name it is not total, and a caller that dereferences the result without a null + * check will NPE the first time the cap is reached. A lookup hit is always returned even at + * capacity, so only the create half can fail. Check {@link #isFull()} beforehand if you want to + * distinguish "refused" from "created" without inspecting the result. + * + *

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

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

The {@code creator} is expected to build an entry whose {@code keyHash} equals {@link * Entry#hash(Object) D1.Entry.hash(key)} -- typically by passing {@code key} to a constructor * that calls {@code super(key)}. A mismatched hash will leave the new entry inserted at a * bucket that future {@link #get} calls won't probe. - * - *

Returns {@code null} once the table is at capacity and {@code key} is absent -- a hit is - * always returned even at capacity, the cap only blocks new entries. */ @Nullable public TEntry getOrCreate( @@ -503,10 +511,15 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { } /** - * Two-key analogue of {@link D1#getOrCreate}. Computes the combined hash once and reuses it for - * both lookup and (on miss) insert. The {@code creator} is expected to build an entry whose - * {@code keyHash} equals {@link Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}. Same - * strict-cap refusal contract as {@link D1#getOrCreate}. + * Two-key analogue of {@link D1#getOrCreate}: returns the entry for {@code (key1, key2)}, + * building one via {@code creator} if absent -- or {@code null} if the pair is absent and the + * table is at capacity. Like the single-key form it is not total despite the name, and + * refusal is a designed steady state rather than an exceptional one; see {@link D1#getOrCreate} + * 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)}. */ @Nullable public TEntry getOrCreate( From 8829af1990187c38172383330c6daead423fb030 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 17:29:01 -0400 Subject: [PATCH 17/41] Rename getOrCreate to tryGetOrCreate on Hashtable and FlatHashtable The name read as total while the method refuses at capacity, and the idiomatic two-liner everyone writes -- getOrCreate(key, ctor) then mutate the result -- NPEs the first time the cap is reached. That ordering is also how the @Nonnull annotation got onto it originally. The prefix marks "this may refuse", matching SizeTracker.tryReserve in the same class. A growable FlatHashtable never exercises it, and that is deliberate: the posture is chosen per instance at the factory while the method name is per class, and the two mistakes are not symmetric -- under-promising refusal costs an NPE at the cap, over-promising costs a redundant null check. Better to over-warn. ConcurrentHashtable keeps the plain getOrCreate for now: it is uncapped, so the name is honest there. It renames when it gains a cap. Also fixes a FlatHashtable javadoc claim that Hashtable.D1's factory counts buckets -- it counts entries, as every table factory in the family now does; only the low-level array allocators take bucket counts. Co-Authored-By: Claude Opus 5 (1M context) --- .../metrics/CardinalityLimitReporter.java | 2 +- .../util/CaseInsensitiveMapBenchmark.java | 6 +- .../datadog/trace/util/FlatHashtable.java | 61 +++++++++-------- .../java/datadog/trace/util/Hashtable.java | 34 +++++----- .../trace/util/FlatHashtableD1Test.java | 14 ++-- .../trace/util/FlatHashtableD2Test.java | 14 ++-- .../datadog/trace/util/FlatHashtableTest.java | 68 ++++++++++--------- .../datadog/trace/util/HashtableD1Test.java | 12 ++-- .../datadog/trace/util/HashtableD2Test.java | 8 +-- 9 files changed, 115 insertions(+), 104 deletions(-) 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 2fb446b1652..fc64b9015d7 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/CardinalityLimitReporter.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/CardinalityLimitReporter.java @@ -58,7 +58,7 @@ 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) { - TagBlockEntry entry = blockedByTag.getOrCreate(tag, TagBlockEntry::new); + TagBlockEntry entry = blockedByTag.tryGetOrCreate(tag, TagBlockEntry::new); if (entry != null) { entry.count += count; } diff --git a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java index 62a48976691..2b4c2e79f57 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java @@ -274,7 +274,8 @@ 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 + // insensitive collisions. tryGetOrCreate 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 // less work (and end up with different final values) than the maps' overwriting put(), a false @@ -284,7 +285,8 @@ static CIEntry[] _create_flat(float loadFactor) { for (String prefix : UPPER_PREFIXES) { String key = prefix + "-" + suffix; CIEntry entry = - FlatHashtable.getOrCreate(table, key, CaseInsensitiveKeyStrategy.INSTANCE, CI_CREATE); + FlatHashtable.tryGetOrCreate( + table, key, CaseInsensitiveKeyStrategy.INSTANCE, CI_CREATE); entry.value = suffix + 1; } } diff --git a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java index 4dc6bf5a2ec..5e078721ba3 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,9 +35,9 @@ * 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 @@ -49,7 +49,7 @@ *

    *
  • 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 +63,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 +73,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 +96,21 @@ protected Entry(long hash) { /** * Single-key, {@code HashMap}-style convenience over the {@linkplain FlatHashtable static core}: - * {@link #get} / {@link #getOrCreate} / {@link #insert} / {@link #forEach} without writing a + * {@link #get} / {@link #tryGetOrCreate} / {@link #insert} / {@link #forEach} without writing a * {@link MatchingStrategy}. Reach for it when you want something quick that beats {@code * HashMap} — the entry carries its own value fields, so updating an existing value is * allocation-free (look up once, then write the returned entry). * *

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

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

    The {@code try} prefix marks "this may refuse" — a growable table simply never exercises + * it. The name has to serve both postures, since the posture is chosen per instance at the + * factory while the method name is per class, and the two mistakes are not symmetric: + * under-promising refusal costs an NPE at the cap, over-promising it costs a redundant null + * check. So it errs toward {@code try}. */ @Nullable - public TEntry getOrCreate(@Nullable K key, @Nonnull CreateStrategy createStrat) { + public TEntry tryGetOrCreate(@Nullable K key, @Nonnull CreateStrategy createStrat) { final TEntry existing = get(key); if (existing != null) { return existing; @@ -300,7 +307,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 +373,7 @@ private D2(TEntry[] table, float loadFactor, boolean growable, int limit) { /** * A bounded {@link D2} holding up to {@code maxCapacity} entries at the {@link - * #DEFAULT_LOAD_FACTOR}, then capping ({@link #getOrCreate} returns {@code null}). + * #DEFAULT_LOAD_FACTOR}, then capping ({@link #tryGetOrCreate} returns {@code null}). */ @Nonnull public static > D2 createFixed( @@ -425,11 +432,11 @@ public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { } /** - * Two-key analogue of {@link D1#getOrCreate}: growable never returns {@code null}; fixed + * Two-key analogue of {@link D1#tryGetOrCreate}: growable never returns {@code null}; fixed * returns {@code null} when full and {@code (key1, key2)} is absent. */ @Nullable - public TEntry getOrCreate( + public TEntry tryGetOrCreate( @Nullable K1 key1, @Nullable K2 key2, @Nonnull CreateStrategy2 createStrat) { @@ -486,7 +493,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 +530,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 +711,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 8c087f16548..ea8bd315d36 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -78,10 +78,10 @@ public final TEntry next() { * *

    Capacity is fixed at construction. The table does not resize, so the caller is responsible * for choosing a capacity appropriate to the working set. Once {@link #size()} reaches that - * capacity, {@link #insert} returns {@code false} and {@link #getOrCreate} 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 + * capacity, {@link #insert} returns {@code false} and {@link #tryGetOrCreate} returns {@code + * null} rather than adding more entries -- a lookup hit is still always returned even at + * capacity, the cap only blocks new entries. Want your own eviction policy instead of a hard cap? + * Drop down to the static building blocks and drive the bucket array yourself -- {@link * Hashtable#createCappedTable(int)} hands you a spine, a {@link SizeTracker}, and an {@link * EvictionCursor} already matched to each other. Actual bucket-array length is rounded up to the * next power of two. @@ -151,8 +151,8 @@ private D1(int maxCapacity) { /** * A capped single-key table: it holds at most {@code maxCapacity} live entries, after - * which {@link #insert} returns {@code false} and {@link #getOrCreate} returns {@code null}. A - * lookup hit is still always returned at capacity -- the cap only blocks new entries. + * which {@link #insert} returns {@code false} and {@link #tryGetOrCreate} returns {@code null}. + * A lookup hit is still always returned at capacity -- the cap only blocks new entries. * *

    "Capped" names the promise, not the mechanism: the bucket array is sized once from {@code * maxCapacity} via {@link Hashtable#capacityFor(int)} and never resized, but that is an @@ -239,8 +239,8 @@ public boolean insert(@Nonnull TEntry newEntry) { * inserts it fresh (returning {@code null}) if absent. Replacing never grows {@link #size()}, * so it always succeeds even on a full table; only a fresh insert can hit the cap, in which * case this throws {@link IllegalStateException} -- unlike {@link #insert} and {@link - * #getOrCreate}, there is no spare return-value slot free to signal refusal without colliding - * with the existing "freshly inserted" {@code null}. + * #tryGetOrCreate}, there is no spare return-value slot free to signal refusal without + * colliding with the existing "freshly inserted" {@code null}. */ @Nullable public TEntry insertOrReplace(@Nonnull TEntry newEntry) { @@ -285,7 +285,7 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { * bucket that future {@link #get} calls won't probe. */ @Nullable - public TEntry getOrCreate( + public TEntry tryGetOrCreate( @Nullable K key, @Nonnull Function creator) { long keyHash = D1.Entry.hash(key); for (TEntry curEntry = bucketFor(this.buckets, keyHash); @@ -428,8 +428,8 @@ private D2(int maxCapacity) { /** * Composite-key analogue of {@link D1#createCapped}: a capped table holding at most * {@code maxCapacity} live entries, after which {@link #insert} returns {@code false} and - * {@link #getOrCreate} returns {@code null}, with lookup hits still always returned. See {@link - * D1#createCapped} for what "capped" promises and why it is the default posture. + * {@link #tryGetOrCreate} returns {@code null}, with lookup hits still always returned. See + * {@link D1#createCapped} for what "capped" promises and why it is the default posture. * *

    {@code entryClass} is a type token only -- it pins the concrete entry type so the compiler * infers {@code K1}, {@code K2}, and {@code TEntry} at the call site (e.g. {@code @@ -511,18 +511,18 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { } /** - * Two-key analogue of {@link D1#getOrCreate}: returns the entry for {@code (key1, key2)}, + * Two-key analogue of {@link D1#tryGetOrCreate}: returns the entry for {@code (key1, key2)}, * building one via {@code creator} if absent -- or {@code null} if the pair is absent and the * table is at capacity. Like the single-key form it is not total despite the name, and - * refusal is a designed steady state rather than an exceptional one; see {@link D1#getOrCreate} - * for the full contract and what to do about a refused create. + * 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)}. */ @Nullable - public TEntry getOrCreate( + public TEntry tryGetOrCreate( @Nullable K1 key1, @Nullable K2 key2, @Nonnull BiFunction creator) { @@ -935,8 +935,8 @@ public boolean isFull() { * 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) -- e.g. {@link - * D1#insert}. When building the entry is itself fallible (e.g. {@link D1#getOrCreate}'s {@code - * creator}), check {@link #isFull()} first, do the fallible work, then call {@link + * D1#insert}. When building the entry is itself fallible (e.g. {@link D1#tryGetOrCreate}'s + * {@code creator}), check {@link #isFull()} first, do the fallible work, then call {@link * #increment()} only once linking actually succeeds. * *

    Returning {@code false} here is not a final refusal -- it's the caller's cue to either diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java index 4fc6838974b..e51462451aa 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java @@ -188,7 +188,7 @@ void getOrCreateOnMissBuildsEntryViaCreator() { FlatHashtable.D1 table = growable(8); int[] createCount = {0}; StringIntEntry created = - table.getOrCreate( + table.tryGetOrCreate( "foo", k -> { createCount[0]++; @@ -209,7 +209,7 @@ void getOrCreateOnHitSkipsCreator() { table.insert(seeded); int[] createCount = {0}; StringIntEntry got = - table.getOrCreate( + table.tryGetOrCreate( "foo", k -> { createCount[0]++; @@ -237,7 +237,7 @@ void growableGrowsPastInitialCapacity() { void growableGetOrCreateNeverReturnsNull() { FlatHashtable.D1 table = growable(1); for (int i = 0; i < 50; i++) { - StringIntEntry e = table.getOrCreate("k" + i, k -> new StringIntEntry(k, 0)); + StringIntEntry e = table.tryGetOrCreate("k" + i, k -> new StringIntEntry(k, 0)); assertNotNull(e); } assertEquals(50, table.size()); @@ -246,15 +246,15 @@ void growableGetOrCreateNeverReturnsNull() { @Test void fixedGetOrCreateCapsWhenFull() { FlatHashtable.D1 table = fixed(2); - assertNotNull(table.getOrCreate("a", k -> new StringIntEntry(k, 1))); - assertNotNull(table.getOrCreate("b", k -> new StringIntEntry(k, 2))); + assertNotNull(table.tryGetOrCreate("a", k -> new StringIntEntry(k, 1))); + assertNotNull(table.tryGetOrCreate("b", k -> new StringIntEntry(k, 2))); assertEquals(2, table.size()); // At capacity, a new key can't be created -> null (caller's overflow default). - assertNull(table.getOrCreate("c", k -> new StringIntEntry(k, 3))); + assertNull(table.tryGetOrCreate("c", k -> new StringIntEntry(k, 3))); assertEquals(2, table.size()); // ...but an existing key still resolves even at capacity (cap blocks creation, not lookup). StringIntEntry a = table.get("a"); - assertSame(a, table.getOrCreate("a", k -> new StringIntEntry(k, 99))); + assertSame(a, table.tryGetOrCreate("a", k -> new StringIntEntry(k, 99))); } @Test diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java index 5d19af4fecf..0900617035e 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java @@ -167,7 +167,7 @@ void getOrCreateOnMissBuildsEntryViaCreator() { FlatHashtable.D2 table = growable(8); int[] createCount = {0}; PairEntry created = - table.getOrCreate( + table.tryGetOrCreate( "foo", 1, (k1, k2) -> { @@ -190,7 +190,7 @@ void getOrCreateOnHitSkipsCreator() { table.insert(seeded); int[] createCount = {0}; PairEntry got = - table.getOrCreate( + table.tryGetOrCreate( "foo", 1, (k1, k2) -> { @@ -217,13 +217,13 @@ void growableGrowsPastInitialCapacity() { @Test void fixedGetOrCreateCapsWhenFull() { FlatHashtable.D2 table = fixed(2); - assertNotNull(table.getOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 1))); - assertNotNull(table.getOrCreate("b", 2, (k1, k2) -> new PairEntry(k1, k2, 2))); + assertNotNull(table.tryGetOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 1))); + assertNotNull(table.tryGetOrCreate("b", 2, (k1, k2) -> new PairEntry(k1, k2, 2))); assertEquals(2, table.size()); - assertNull(table.getOrCreate("c", 3, (k1, k2) -> new PairEntry(k1, k2, 3))); + assertNull(table.tryGetOrCreate("c", 3, (k1, k2) -> new PairEntry(k1, k2, 3))); assertEquals(2, table.size()); PairEntry a = table.get("a", 1); - assertSame(a, table.getOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 99))); + assertSame(a, table.tryGetOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 99))); } @Test @@ -258,7 +258,7 @@ void hashCollisionsResolveByKeyEquality() { void growableGetOrCreateGrowsPastInitialCapacity() { FlatHashtable.D2 table = growable(1); for (int i = 0; i < 50; i++) { - PairEntry e = table.getOrCreate("k", i, (k1, k2) -> new PairEntry(k1, k2, k2)); + PairEntry e = table.tryGetOrCreate("k", i, (k1, k2) -> new PairEntry(k1, k2, k2)); assertNotNull(e); } assertEquals(50, table.size()); diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java index b1099210611..0ecf3712d3f 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java @@ -185,10 +185,10 @@ void create_allocatesTypedTableOfCapacity() { @Test void getOrCreate_insertsOnceAndReturnsTheExistingEntry() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); - TestEntry first = FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); + TestEntry first = FlatHashtable.tryGetOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); assertEquals("a", first.key); // A second call must return the SAME instance, not mint a new one. - assertSame(first, FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE)); + assertSame(first, FlatHashtable.tryGetOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE)); assertSame(first, FlatHashtable.get(table, "a", TestEntryStrategy.INSTANCE)); } @@ -196,7 +196,7 @@ void getOrCreate_insertsOnceAndReturnsTheExistingEntry() { void get_returnsNullForAbsentKey() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); assertNull(FlatHashtable.get(table, "missing", TestEntryStrategy.INSTANCE)); - FlatHashtable.getOrCreate(table, "present", TestEntryStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "present", TestEntryStrategy.INSTANCE, CREATE); assertNull(FlatHashtable.get(table, "still-missing", TestEntryStrategy.INSTANCE)); } @@ -204,14 +204,16 @@ void get_returnsNullForAbsentKey() { void getOrCreate_returnsNullWhenTableIsFull() { // capacityFor(1) == 2 slots. TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); - assertTrue(FlatHashtable.getOrCreate(table, "k0", TestEntryStrategy.INSTANCE, CREATE) != null); - assertTrue(FlatHashtable.getOrCreate(table, "k1", TestEntryStrategy.INSTANCE, CREATE) != null); + assertTrue( + FlatHashtable.tryGetOrCreate(table, "k0", TestEntryStrategy.INSTANCE, CREATE) != null); + assertTrue( + FlatHashtable.tryGetOrCreate(table, "k1", TestEntryStrategy.INSTANCE, CREATE) != null); // Both slots occupied by distinct keys -> a third distinct key finds no room. - assertNull(FlatHashtable.getOrCreate(table, "k2", TestEntryStrategy.INSTANCE, CREATE)); + assertNull(FlatHashtable.tryGetOrCreate(table, "k2", TestEntryStrategy.INSTANCE, CREATE)); // ...but an existing key still resolves even when full. assertSame( FlatHashtable.get(table, "k0", TestEntryStrategy.INSTANCE), - FlatHashtable.getOrCreate(table, "k0", TestEntryStrategy.INSTANCE, CREATE)); + FlatHashtable.tryGetOrCreate(table, "k0", TestEntryStrategy.INSTANCE, CREATE)); } @Test @@ -225,11 +227,11 @@ void hashKey_isStableForEqualKeys() { void collision_probesPastOccupiedSlots_andResolvesEach() { // 8 slots; COLLIDING sends all to slot 0 TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); - TestEntry a = FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry a = FlatHashtable.tryGetOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); // slot 0 taken -> 1 - TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry b = FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); // -> slot 2 - TestEntry c = FlatHashtable.getOrCreate(table, "c", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry c = FlatHashtable.tryGetOrCreate(table, "c", TestCollidingStrategy.INSTANCE, CREATE); assertNotSame(a, b); assertNotSame(b, c); @@ -240,7 +242,7 @@ void collision_probesPastOccupiedSlots_andResolvesEach() { assertSame(c, FlatHashtable.get(table, "c", TestCollidingStrategy.INSTANCE)); // existing colliding key: found after probing, no new entry minted - assertSame(b, FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE)); + assertSame(b, FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE)); // absent key: probe past the 3 occupied slots, hit an empty slot -> null assertNull(FlatHashtable.get(table, "absent", TestCollidingStrategy.INSTANCE)); @@ -251,9 +253,9 @@ void collision_probeWrapsAroundToFront() { // 2 slots (0,1), mask=1; LAST_SLOT starts at 1 TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // -> slot 1 - TestEntry k0 = FlatHashtable.getOrCreate(table, "k0", TestLastSlotStrategy.INSTANCE, CREATE); + TestEntry k0 = FlatHashtable.tryGetOrCreate(table, "k0", TestLastSlotStrategy.INSTANCE, CREATE); // taken -> wraps to 0 - TestEntry k1 = FlatHashtable.getOrCreate(table, "k1", TestLastSlotStrategy.INSTANCE, CREATE); + TestEntry k1 = FlatHashtable.tryGetOrCreate(table, "k1", TestLastSlotStrategy.INSTANCE, CREATE); assertNotSame(k0, k1); assertSame(k0, FlatHashtable.get(table, "k0", TestLastSlotStrategy.INSTANCE)); @@ -264,9 +266,9 @@ void collision_probeWrapsAroundToFront() { @Test void get_returnsNullWhenTableFullAndKeyAbsent() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots - FlatHashtable.getOrCreate(table, "k0", TestCollidingStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "k0", TestCollidingStrategy.INSTANCE, CREATE); // fills slots 0 and 1 - FlatHashtable.getOrCreate(table, "k1", TestCollidingStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "k1", TestCollidingStrategy.INSTANCE, CREATE); // get() probes both occupied slots, wraps back to start -> null (get's full-wrap branch) assertNull(FlatHashtable.get(table, "absent", TestCollidingStrategy.INSTANCE)); @@ -302,9 +304,9 @@ void insert_returnsFalseWhenFull() { @Test void forEach_visitsEveryEntry() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); - FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); - FlatHashtable.getOrCreate(table, "b", TestEntryStrategy.INSTANCE, CREATE); - FlatHashtable.getOrCreate(table, "c", TestEntryStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "b", TestEntryStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "c", TestEntryStrategy.INSTANCE, CREATE); Set seen = new HashSet<>(); FlatHashtable.forEach(table, e -> seen.add(e.key)); @@ -314,8 +316,8 @@ void forEach_visitsEveryEntry() { @Test void forEach_contextVariant_passesContextWithoutCapture() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); - FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); - FlatHashtable.getOrCreate(table, "b", TestEntryStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "b", TestEntryStrategy.INSTANCE, CREATE); Set seen = new HashSet<>(); FlatHashtable.forEach(table, seen, (ctx, e) -> ctx.add(e.key)); @@ -325,9 +327,9 @@ void forEach_contextVariant_passesContextWithoutCapture() { @Test void iterator_yieldsEveryEntrySharingTheHash() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); // COLLIDING sends all to slot 0 - TestEntry a = FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); - TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); - TestEntry c = FlatHashtable.getOrCreate(table, "c", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry a = FlatHashtable.tryGetOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry b = FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry c = FlatHashtable.tryGetOrCreate(table, "c", TestCollidingStrategy.INSTANCE, CREATE); Set seen = new HashSet<>(); Iterator it = FlatHashtable.iterator(table, 0, TestCollidingStrategy.INSTANCE); @@ -340,8 +342,8 @@ void iterator_yieldsEveryEntrySharingTheHash() { @Test void iterator_filtersOutEntriesWithADifferentHash() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); // entries at slot 0, hashOf == 0 - FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); - FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); // a hash that shares the entries' home slot (0) but that no stored entry has as its hashOf long sameHomeOtherHash = hashLandingOn(0, table.length - 1); @@ -363,8 +365,8 @@ void iterator_fullTable_yieldsMatchesIncludingTheWrappingSlot() { // 2 slots, both filled by colliding (hash 0) entries -> the probe has no empty slot to stop at, // so the traversal must yield the entry on the wrapping slot and then terminate on wrap. TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); - TestEntry a = FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); - TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry a = FlatHashtable.tryGetOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry b = FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); Set seen = new HashSet<>(); Iterator it = FlatHashtable.iterator(table, 0, TestCollidingStrategy.INSTANCE); @@ -379,8 +381,8 @@ void iterator_fullTable_absentHash_terminatesOnWrap() { // Full table, iterating a hash no stored entry has (all hashOf == 0) -> the traversal walks // every slot and wraps without ever hitting an empty one, then reports no elements. TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); - FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); - FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); Iterator it = FlatHashtable.iterator(table, 5, TestCollidingStrategy.INSTANCE); assertFalse(it.hasNext()); @@ -543,7 +545,7 @@ void entryIterator_emptyRunHasNoNext() { void caseInsensitiveStrategy_matchesRegardlessOfCase() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); TestEntry stored = - FlatHashtable.getOrCreate( + FlatHashtable.tryGetOrCreate( table, "Content-Type", TestCaseInsensitiveStrategy.INSTANCE, CREATE); // Look-ups in any case resolve to the same stored entry, allocation-free. @@ -553,10 +555,10 @@ void caseInsensitiveStrategy_matchesRegardlessOfCase() { stored, FlatHashtable.get(table, "CONTENT-TYPE", TestCaseInsensitiveStrategy.INSTANCE)); assertSame( stored, FlatHashtable.get(table, "cOnTeNt-TyPe", TestCaseInsensitiveStrategy.INSTANCE)); - // getOrCreate with a differently-cased key does not mint a second entry. + // tryGetOrCreate with a differently-cased key does not mint a second entry. assertSame( stored, - FlatHashtable.getOrCreate( + FlatHashtable.tryGetOrCreate( table, "CONTENT-TYPE", TestCaseInsensitiveStrategy.INSTANCE, CREATE)); assertNull(FlatHashtable.get(table, "content-length", TestCaseInsensitiveStrategy.INSTANCE)); } @@ -575,7 +577,7 @@ void caseInsensitiveStrategy_doesNotFalseMissOnSupplementaryCasePair() { String s2 = new String(Character.toChars(0x10428)); // DESERET SMALL LETTER LONG I TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); TestEntry stored = - FlatHashtable.getOrCreate(table, s1, TestCaseInsensitiveStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, s1, TestCaseInsensitiveStrategy.INSTANCE, CREATE); if (s1.equalsIgnoreCase(s2)) { assertSame(stored, FlatHashtable.get(table, s2, TestCaseInsensitiveStrategy.INSTANCE)); diff --git a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java index df17f06b9f8..1ae0b70cd7b 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -202,7 +202,7 @@ void getOrCreateOnMissBuildsEntryViaCreator() { Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); int[] createCount = {0}; StringIntEntry created = - table.getOrCreate( + table.tryGetOrCreate( "foo", k -> { createCount[0]++; @@ -223,7 +223,7 @@ void getOrCreateOnHitSkipsCreator() { table.insert(seeded); int[] createCount = {0}; StringIntEntry got = - table.getOrCreate( + table.tryGetOrCreate( "foo", k -> { createCount[0]++; @@ -237,11 +237,11 @@ void getOrCreateOnHitSkipsCreator() { @Test void getOrCreateNullKeyIsPermitted() { Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); - StringIntEntry created = table.getOrCreate(null, k -> new StringIntEntry(k, 7)); + StringIntEntry created = table.tryGetOrCreate(null, k -> new StringIntEntry(k, 7)); assertNotNull(created); assertNull(created.key); assertEquals(7, created.value); - assertSame(created, table.getOrCreate(null, k -> new StringIntEntry(k, 999))); + assertSame(created, table.tryGetOrCreate(null, k -> new StringIntEntry(k, 999))); assertEquals(1, table.size()); } @@ -261,10 +261,10 @@ void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); - assertNull(table.getOrCreate("c", k -> new StringIntEntry(k, 3))); + assertNull(table.tryGetOrCreate("c", k -> new StringIntEntry(k, 3))); assertEquals(2, table.size()); - StringIntEntry hit = table.getOrCreate("a", k -> new StringIntEntry(k, 999)); + StringIntEntry hit = table.tryGetOrCreate("a", k -> new StringIntEntry(k, 999)); assertEquals(1, hit.value, "existing entry is still returned even at capacity"); } 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 fb0f6596b86..78bd5a981e6 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java @@ -85,7 +85,7 @@ void getOrCreateOnMissBuildsEntryViaCreator() { Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); int[] createCount = {0}; PairEntry created = - table.getOrCreate( + table.tryGetOrCreate( "a", 1, (k1, k2) -> { @@ -108,7 +108,7 @@ void getOrCreateOnHitSkipsCreator() { table.insert(seeded); int[] createCount = {0}; PairEntry got = - table.getOrCreate( + table.tryGetOrCreate( "a", 1, (k1, k2) -> { @@ -199,10 +199,10 @@ void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); - assertNull(table.getOrCreate("c", 3, (k1, k2) -> new PairEntry(k1, k2, 300))); + assertNull(table.tryGetOrCreate("c", 3, (k1, k2) -> new PairEntry(k1, k2, 300))); assertEquals(2, table.size()); - PairEntry hit = table.getOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 999)); + PairEntry hit = table.tryGetOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 999)); assertEquals(100, hit.value, "existing entry is still returned even at capacity"); } From 5a9c328fd3a2245341b95f59853b9f9eb2077a72 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 17:51:49 -0400 Subject: [PATCH 18/41] Replace Hashtable insertOrReplace with a refusing tryInsertOrReplace Addresses two review comments on #12101. Throwing IllegalStateException at capacity was the odd one out: insert returns false and tryGetOrCreate returns null, so one class had three refusal conventions. A cap is designed steady-state behaviour rather than a programming error, and an exception allocates a throwable plus stack trace exactly when the table is under the most pressure -- the failure path costing more than the happy path. The throw existed because null already meant "inserted fresh", leaving no spare return value for "refused". Dropping the prior-entry return frees one up: Map.put's return value is rarely read, and a caller that wants it can get() first. So the operation becomes a plain boolean, false only when the key is absent and the table is full -- a replacement swaps one entry for another without growing, so it always succeeds. That also lets the fresh-insert path go through the size-tracked static insertHeadEntryFor(sizeTracker, ...) instead of a separate tryReserve followed by the untracked form, so the class now uses the same one-call shape it offers composers. tryGetOrCreate deliberately keeps isFull() -> create -> increment: its creator runs between the check and the link and may throw, so a slot reserved up front could leak. Commented at the call site so the asymmetry does not read as an oversight. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/datadog/trace/util/Hashtable.java | 59 ++++++++++--------- .../datadog/trace/util/HashtableD1Test.java | 21 +++---- .../datadog/trace/util/HashtableD2Test.java | 22 +++---- 3 files changed, 55 insertions(+), 47 deletions(-) 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 ea8bd315d36..692a3495e05 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -211,7 +211,7 @@ public TEntry remove(@Nullable K key) { // `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 - // insertOrReplace below. + // tryInsertOrReplace below. long keyHash = D1.Entry.hash(key); for (MutatingBucketIterator iter = mutatingBucketIterator(this.buckets, keyHash); iter.hasNext(); ) { @@ -235,15 +235,23 @@ public boolean insert(@Nonnull TEntry newEntry) { } /** - * Replaces the existing entry for {@code newEntry}'s key (returning the prior entry), or - * inserts it fresh (returning {@code null}) if absent. Replacing never grows {@link #size()}, - * so it always succeeds even on a full table; only a fresh insert can hit the cap, in which - * case this throws {@link IllegalStateException} -- unlike {@link #insert} and {@link - * #tryGetOrCreate}, there is no spare return-value slot free to signal refusal without - * colliding with the existing "freshly inserted" {@code null}. + * 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. */ - @Nullable - public TEntry insertOrReplace(@Nonnull TEntry newEntry) { + public boolean tryInsertOrReplace(@Nonnull TEntry newEntry) { for (MutatingBucketIterator iter = mutatingBucketIterator(this.buckets, newEntry.keyHash); iter.hasNext(); ) { @@ -251,16 +259,11 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { if (curEntry.matches(newEntry.key)) { iter.replace(newEntry); - return curEntry; + return true; } } - if (!this.sizeTracker.tryReserve()) { - throw new IllegalStateException( - "Hashtable.D1 is at capacity (" + this.sizeTracker.capacity() + ")"); - } - insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); - return null; + return insertHeadEntryFor(this.sizeTracker, this.buckets, newEntry.keyHash, newEntry); } /** @@ -295,6 +298,10 @@ public TEntry tryGetOrCreate( return curEntry; } } + // Deliberately isFull() -> create -> increment, rather than the one-call tracked + // insertHeadEntryFor(sizeTracker, ...) that insert/tryInsertOrReplace use: `creator` runs + // between the check and the link and may throw, so a slot reserved up front could leak. See + // SizeTracker#tryReserve. if (this.sizeTracker.isFull()) { return null; } @@ -349,7 +356,7 @@ public void drain(C context, @Nonnull BiConsumer *

    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. * @@ -488,9 +495,8 @@ public boolean insert(@Nonnull TEntry newEntry) { return insertHeadEntryFor(this.sizeTracker, this.buckets, newEntry.keyHash, newEntry); } - /** Two-key analogue of {@link D1#insertOrReplace}, with the same refusal contract. */ - @Nullable - public TEntry insertOrReplace(@Nonnull TEntry newEntry) { + /** Two-key analogue of {@link D1#tryInsertOrReplace}, with the same refusal contract. */ + public boolean tryInsertOrReplace(@Nonnull TEntry newEntry) { for (MutatingBucketIterator iter = mutatingBucketIterator(this.buckets, newEntry.keyHash); iter.hasNext(); ) { @@ -498,16 +504,11 @@ public TEntry insertOrReplace(@Nonnull TEntry newEntry) { if (curEntry.matches(newEntry.key1, newEntry.key2)) { iter.replace(newEntry); - return curEntry; + return true; } } - if (!this.sizeTracker.tryReserve()) { - throw new IllegalStateException( - "Hashtable.D2 is at capacity (" + this.sizeTracker.capacity() + ")"); - } - insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); - return null; + return insertHeadEntryFor(this.sizeTracker, this.buckets, newEntry.keyHash, newEntry); } /** @@ -534,6 +535,10 @@ public TEntry tryGetOrCreate( return curEntry; } } + // Deliberately isFull() -> create -> increment, rather than the one-call tracked + // insertHeadEntryFor(sizeTracker, ...) that insert/tryInsertOrReplace use: `creator` runs + // between the check and the link and may throw, so a slot reserved up front could leak. See + // SizeTracker#tryReserve. if (this.sizeTracker.isFull()) { return null; } 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 1ae0b70cd7b..aba39aa9296 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -8,7 +8,6 @@ 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; @@ -90,15 +89,15 @@ void removeNonexistentReturnsNullAndDoesNotChangeSize() { } @Test - void insertOrReplaceReturnsPriorEntryOrNullOnInsert() { + void tryInsertOrReplaceInsertsThenReplacesWithoutGrowing() { Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); StringIntEntry first = new StringIntEntry("k", 1); - assertNull(table.insertOrReplace(first), "fresh insert returns null"); + assertTrue(table.tryInsertOrReplace(first), "fresh insert accepted"); assertEquals(1, table.size()); StringIntEntry second = new StringIntEntry("k", 2); - assertSame(first, table.insertOrReplace(second), "replace returns the prior entry"); - assertEquals(1, table.size()); + assertTrue(table.tryInsertOrReplace(second), "replace accepted"); + assertEquals(1, table.size(), "replacing an existing key does not grow the table"); assertSame(second, table.get("k"), "new entry visible after replace"); } @@ -269,20 +268,22 @@ void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { } @Test - void insertOrReplaceStillReplacesAtCapacityButThrowsOnFreshInsert() { + void tryInsertOrReplaceStillReplacesAtCapacityButRefusesFreshInsert() { Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); StringIntEntry replacement = new StringIntEntry("a", 99); - StringIntEntry prior = table.insertOrReplace(replacement); - assertEquals(1, prior.value); + assertTrue( + table.tryInsertOrReplace(replacement), "replacing an existing key succeeds when full"); assertSame(replacement, table.get("a")); assertEquals(2, table.size()); - assertThrows( - IllegalStateException.class, () -> table.insertOrReplace(new StringIntEntry("c", 3))); + assertFalse( + table.tryInsertOrReplace(new StringIntEntry("c", 3)), + "a fresh insert is refused, not thrown"); assertEquals(2, table.size()); + assertNull(table.get("c")); } @Test 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 78bd5a981e6..f513299242e 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java @@ -5,7 +5,6 @@ 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; @@ -44,15 +43,16 @@ void removePairUnlinks() { } @Test - void insertOrReplaceMatchesOnBothKeys() { + void tryInsertOrReplaceMatchesOnBothKeys() { Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); PairEntry first = new PairEntry("k", 7, 1); - assertNull(table.insertOrReplace(first)); + assertTrue(table.tryInsertOrReplace(first)); PairEntry second = new PairEntry("k", 7, 2); - assertSame(first, table.insertOrReplace(second)); + assertTrue(table.tryInsertOrReplace(second)); + assertSame(second, table.get("k", 7), "same key pair replaced in place"); // Different second-key: should insert new, not replace PairEntry third = new PairEntry("k", 8, 3); - assertNull(table.insertOrReplace(third)); + assertTrue(table.tryInsertOrReplace(third)); assertEquals(2, table.size()); } @@ -207,20 +207,22 @@ void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { } @Test - void insertOrReplaceStillReplacesAtCapacityButThrowsOnFreshInsert() { + void tryInsertOrReplaceStillReplacesAtCapacityButRefusesFreshInsert() { Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); PairEntry replacement = new PairEntry("a", 1, 999); - PairEntry prior = table.insertOrReplace(replacement); - assertEquals(100, prior.value); + assertTrue( + table.tryInsertOrReplace(replacement), "replacing an existing key succeeds when full"); assertSame(replacement, table.get("a", 1)); assertEquals(2, table.size()); - assertThrows( - IllegalStateException.class, () -> table.insertOrReplace(new PairEntry("c", 3, 300))); + 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 From 09c356f7fb2c8cc4f2123cf49d36c8d585eb8135 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 18:00:43 -0400 Subject: [PATCH 19/41] Clean up Hashtable comments: drop outward references, order by use Two doc-only changes; no behavior change. Stop naming other classes. Hashtable referenced ConcurrentHashtable (three of them {@link}s to a class that is not in this tree, so dangling) and AggregateTable, a downstream consumer in dd-trace-core -- an inverted dependency for a low-level util to document. FlatHashtable comparisons went too: the three are related in design, not in any dependency sense, and a reader of this class should not need the other two loaded to understand it. The reasoning those references carried is kept, just stated on its own terms -- why bucketFor is not called bucket, why insertHeadEntryAt/For are not one overloaded name, why a chained table can run a higher load factor than an open-addressed one. Order members by expected use, leading with creation. D1, D2 and the static building blocks now all read: create, then access (get / insert / tryGetOrCreate / forEach), then the bulk clear / drain / eviction routines, then supporting types. Previously the iterator factories sat after drain, and clear came before the traversal methods. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/datadog/trace/util/Hashtable.java | 165 +++++++++--------- 1 file changed, 78 insertions(+), 87 deletions(-) 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 692a3495e05..9aa5efe09ff 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -311,11 +311,6 @@ public TEntry tryGetOrCreate( return newEntry; } - public void clear() { - Hashtable.clear(this.buckets); - this.sizeTracker.reset(); - } - public void forEach(@Nonnull Consumer consumer) { Hashtable.forEach(this.buckets, consumer); } @@ -329,6 +324,11 @@ public void forEach(C context, @Nonnull BiConsumer consumer) { Hashtable.forEach(this.buckets, consumer); } @@ -566,6 +561,11 @@ public void forEach(C context, @Nonnull BiConsumer void drain(C context, @Nonnull BiConsumer // 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. This is the same "static functions over a - // caller-owned array" shape as the concurrent variant (ConcurrentHashtable); see how - // AggregateTable drives a Hashtable.Entry[] with these. The calling class owns the array and + // 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 @@ -607,13 +605,12 @@ public void drain(C context, @Nonnull BiConsumer * Allocates a fixed-size bucket array sized to hold {@code capacity} entries: {@code capacity} * rounded up to the next power of two. * - *

    Unlike the concurrent variant's {@code createFixedBuckets} (whose {@code - * AtomicReferenceArray} spine has an erased element type), this class's spine is a genuine {@code - * E[]}, so {@code entryClass} is reflectively allocated into it via {@link Array#newInstance} -- - * same idiom as {@code FlatHashtable#create(Class, int)}. That gives the returned array 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. Capacity is fixed; the table - * does not resize. + *

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

    {@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 @@ -650,10 +647,9 @@ public static Hashtable.Entry[] create(int buckets) { /** * Balanced default load factor for a chained bucket array: at this target fill, chains from a * well-spread hash stay short (average chain length {@code ~1/DEFAULT_LOAD_FACTOR}) without - * over-provisioning the array. Mirrors {@code FlatHashtable#DEFAULT_LOAD_FACTOR} in spirit, - * though the two aren't comparable numerically -- chaining degrades gracefully past 1.0 fill - * (longer chains, not failure), unlike open addressing, so this class can run a higher target - * fill than {@code FlatHashtable}'s. + * 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; @@ -683,8 +679,7 @@ public static int capacityFor(int cardinalityLimit, float 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. The concurrent variant shares this so the two families - * round identically. + * negative inputs or inputs above the cap. */ public static int sizeFor(int requestedSize) { if (requestedSize < 0) { @@ -709,11 +704,10 @@ public static int bucketIndex(@Nonnull Object[] buckets, long keyHash) { * 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 to match {@link ConcurrentHashtable#bucketFor} rather than {@code bucket}: this class - * has no competing {@code int}-index overload today, but naming it {@code bucketFor} up front - * keeps the two classes' static building blocks aligned and avoids reintroducing the {@code - * bucket}/{@code insertHeadEntry} int-vs-long overload ambiguity that {@link ConcurrentHashtable} - * had to rename its way out of. + *

    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 @@ -738,11 +732,11 @@ public static void insertHeadEntryAt( * 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}) for the same reason {@link ConcurrentHashtable#insertHeadEntryFor} is: 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. + *

    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) { @@ -755,8 +749,8 @@ public static void insertHeadEntryFor( * sizeTracker} first, splicing {@code entry} in only if the reservation succeeds. Returns {@code * false} (without touching {@code buckets}) once {@code sizeTracker} is at capacity. Lets a * composer working directly against the static building blocks (e.g. {@link D1#insert}, or a - * caller-owned table like client-side stats' {@code AggregateTable}) get the same one-call - * insert-with-cap-check contract that {@link D1}/{@link D2} give their own callers. + * 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 sizeTracker} 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 @@ -804,10 +798,6 @@ public static TEntry removeMatching( return null; } - public static void clear(@Nonnull Hashtable.Entry[] buckets) { - Arrays.fill(buckets, null); - } - /** * 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 @@ -840,31 +830,6 @@ public static void forEach( } } - /** - * 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. - */ - public static void drain( - @Nonnull Hashtable.Entry[] buckets, @Nonnull Consumer sink) { - Hashtable.forEach(buckets, sink); - clear(buckets); - } - - /** - * 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. - */ - public static void drain( - @Nonnull Hashtable.Entry[] buckets, - C context, - @Nonnull BiConsumer sink) { - Hashtable.forEach(buckets, context, sink); - clear(buckets); - } - @Nonnull public static BucketIterator bucketIterator( @Nonnull Hashtable.Entry[] buckets, long keyHash) { @@ -890,11 +855,11 @@ MutatingTableIterator mutatingTableIterator(@Nonnull Hashtable.Entry[] b /** * 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. + * bucket range {@code [startBucket, endBucket)}. Useful for resumable sweeps -- e.g. the + * cursor-based eviction in {@link EvictionCursor} -- 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]}. @@ -906,12 +871,40 @@ MutatingTableIterator mutatingTableIterator( return new MutatingTableIterator(buckets, startBucket, endBucket); } + public static void clear(@Nonnull Hashtable.Entry[] buckets) { + Arrays.fill(buckets, null); + } + + /** + * 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. + */ + public static void drain( + @Nonnull Hashtable.Entry[] buckets, @Nonnull Consumer sink) { + Hashtable.forEach(buckets, sink); + clear(buckets); + } + + /** + * 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. + */ + public static void drain( + @Nonnull Hashtable.Entry[] buckets, + C context, + @Nonnull BiConsumer sink) { + Hashtable.forEach(buckets, context, sink); + clear(buckets); + } + /** * Tracks a live entry count against a fixed capacity. {@link D1} and {@link D2} use this * internally for their strict entry-count cap; other composers of the static building blocks - * above -- e.g. client-side stats' {@code AggregateTable}, which drives a {@code - * Hashtable.Entry[]} directly -- can reuse it instead of hand-rolling the same - * increment/decrement/cap-check bookkeeping. + * above -- those driving a {@code Hashtable.Entry[]} directly -- can reuse it instead of + * hand-rolling the same increment/decrement/cap-check bookkeeping. * *

    Not thread-safe, matching the rest of this class. */ @@ -978,8 +971,7 @@ public void reset() { * *

    Pairs with {@link SizeTracker}: when {@link SizeTracker#tryReserve()} refuses because the * table is full, a composer can call {@link #evictOne} to make room and retry, or give up if - * nothing was evictable. Factored out of client-side stats' {@code AggregateTable}, which - * originally hand-rolled this same cursor-resumed two-pass scan. + * nothing was evictable. * *

    Not thread-safe, matching the rest of this class. */ @@ -1048,11 +1040,11 @@ public void reset() { /** * Bundles a bucket array together with a {@link SizeTracker} and {@link EvictionCursor} sized and - * matched to it, so a composer driving the static building blocks directly (e.g. client-side - * stats' {@code AggregateTable}) gets everything it needs to store from one factory call, instead - * of separately sizing an array and a tracker that must stay in sync with it. Same headroom idiom - * as {@link D1}/{@link D2}'s constructors: {@code capacity} is the strict cap on live entries, - * and the backing array is sized with load-factor headroom over it. + * matched to it, so a composer driving the static building blocks directly gets everything it + * needs to store from one factory call, instead of separately sizing an array and a tracker that + * must stay in sync with it. Same headroom idiom as {@link D1}/{@link D2}'s constructors: {@code + * capacity} is the strict cap on live entries, and the backing array is sized with load-factor + * headroom over it. * *

    Store the pieces of this bundle into your own fields; nothing here is meant to be held onto * as a {@code Table} itself. @@ -1081,12 +1073,11 @@ public static Table createCappedTable(int maxCapacity) { /** * Deprecated facade over the static building blocks that are now methods on {@link Hashtable} - * itself (mirroring the concurrent variant). 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. + * 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 (e.g. client-side statistics). - * New code should call the {@code Hashtable.*} statics directly. + *

    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. */ From 5851b46c921066e640c24ad30209c06e7277736e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 18:20:17 -0400 Subject: [PATCH 20/41] Fold SizeTracker and EvictionCursor into one SizeManager Reserving a slot and evicting to make room are two directions of the same policy, so they belong on one object. Keeping them apart meant a caller had to wire a cursor to a tracker, then remember to decrement after every unlink -- a missed decrement leaks the cap silently until the table stops accepting anything. Folded, that whole class of mistake disappears: there is no second object to mis-wire, and every eviction maintains the count because the count is right there. It also lets the two halves compose into the call a self-evicting table's miss path actually wants: if (!sizeManager.tryReserveOrEvict(buckets, STALE)) { return null; // full and nothing evictable } replacing an isFull() check followed by a hand-rolled evict-and-retry. Also renames the cursor's full-pass drain to evictAll, so it no longer collides with Hashtable.drain -- one removes what matches and returns a count, the other empties the table into a sink. And adds the tracked clear(sizeManager, buckets), which resets the count along with the spine; D1/D2.clear now use it instead of pairing the two calls by hand. Table drops to buckets + sizeManager. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/datadog/trace/util/Hashtable.java | 244 ++++++++++-------- .../datadog/trace/util/HashtableTest.java | 64 +++-- 2 files changed, 179 insertions(+), 129 deletions(-) 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 9aa5efe09ff..8abb1e1c19d 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -82,9 +82,9 @@ public final TEntry next() { * 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#createCappedTable(int)} hands you a spine, a {@link SizeTracker}, and an {@link - * EvictionCursor} already matched to each other. Actual bucket-array length is rounded up to the - * next power of two. + * Hashtable#createCappedTable(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}. @@ -140,13 +140,13 @@ public static long hash(@Nullable Object key) { // 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 final SizeTracker sizeTracker; + private final SizeManager sizeManager; 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.sizeTracker = new SizeTracker(maxCapacity); + this.sizeManager = new SizeManager(maxCapacity); } /** @@ -159,7 +159,7 @@ private D1(int maxCapacity) { * 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 - * SizeTracker} with an {@link EvictionCursor} over the static building blocks (see {@link + * SizeManager}'s eviction half over the static building blocks (see {@link * Hashtable#createCappedTable(int)}) rather than reaching for an uncapped table. * *

    Pick {@code maxCapacity} in the right ballpark of what you actually expect to hold @@ -167,7 +167,7 @@ private D1(int maxCapacity) { * 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 SizeTracker(limit)}. + * {@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 @@ -184,12 +184,12 @@ public static > D1 createCapped( } public int size() { - return this.sizeTracker.size(); + return this.sizeManager.size(); } /** {@code true} once {@link #size()} has reached this table's fixed capacity. */ public boolean isFull() { - return this.sizeTracker.isFull(); + return this.sizeManager.isFull(); } @Nullable @@ -218,7 +218,7 @@ public TEntry remove(@Nullable K key) { TEntry curEntry = iter.next(); if (curEntry.matches(key)) { iter.remove(); - this.sizeTracker.decrement(); + this.sizeManager.decrement(); return curEntry; } } @@ -231,7 +231,7 @@ public TEntry remove(@Nullable K key) { * shadowed behind the existing entry. */ public boolean insert(@Nonnull TEntry newEntry) { - return insertHeadEntryFor(this.sizeTracker, this.buckets, newEntry.keyHash, newEntry); + return insertHeadEntryFor(this.sizeManager, this.buckets, newEntry.keyHash, newEntry); } /** @@ -263,7 +263,7 @@ public boolean tryInsertOrReplace(@Nonnull TEntry newEntry) { } } - return insertHeadEntryFor(this.sizeTracker, this.buckets, newEntry.keyHash, newEntry); + return insertHeadEntryFor(this.sizeManager, this.buckets, newEntry.keyHash, newEntry); } /** @@ -299,15 +299,15 @@ public TEntry tryGetOrCreate( } } // Deliberately isFull() -> create -> increment, rather than the one-call tracked - // insertHeadEntryFor(sizeTracker, ...) that insert/tryInsertOrReplace use: `creator` runs + // 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 - // SizeTracker#tryReserve. - if (this.sizeTracker.isFull()) { + // SizeManager#tryReserve. + if (this.sizeManager.isFull()) { return null; } TEntry newEntry = creator.apply(key); insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); - this.sizeTracker.increment(); + this.sizeManager.increment(); return newEntry; } @@ -325,8 +325,7 @@ public void forEach(C context, @Nonnull BiConsumer sink) { Hashtable.drain(this.buckets, sink); - this.sizeTracker.reset(); + this.sizeManager.reset(); } /** @@ -346,7 +345,7 @@ public void drain(@Nonnull Consumer sink) { */ public void drain(C context, @Nonnull BiConsumer sink) { Hashtable.drain(this.buckets, context, sink); - this.sizeTracker.reset(); + this.sizeManager.reset(); } } @@ -423,13 +422,13 @@ public static long hash(@Nullable Object key1, @Nullable Object key2) { // Package-private to match D1.buckets -- available for iterator tests in the same package. final Hashtable.Entry[] buckets; - private final SizeTracker sizeTracker; + private final SizeManager sizeManager; 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.sizeTracker = new SizeTracker(maxCapacity); + this.sizeManager = new SizeManager(maxCapacity); } /** @@ -452,12 +451,12 @@ public static > D2 creat } public int size() { - return this.sizeTracker.size(); + return this.sizeManager.size(); } /** {@code true} once {@link #size()} has reached this table's fixed capacity. */ public boolean isFull() { - return this.sizeTracker.isFull(); + return this.sizeManager.isFull(); } @Nullable @@ -483,7 +482,7 @@ public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { TEntry curEntry = iter.next(); if (curEntry.matches(key1, key2)) { iter.remove(); - this.sizeTracker.decrement(); + this.sizeManager.decrement(); return curEntry; } } @@ -492,7 +491,7 @@ public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { /** Two-key analogue of {@link D1#insert}, with the same strict-cap refusal contract. */ public boolean insert(@Nonnull TEntry newEntry) { - return insertHeadEntryFor(this.sizeTracker, this.buckets, newEntry.keyHash, newEntry); + return insertHeadEntryFor(this.sizeManager, this.buckets, newEntry.keyHash, newEntry); } /** Two-key analogue of {@link D1#tryInsertOrReplace}, with the same refusal contract. */ @@ -508,7 +507,7 @@ public boolean tryInsertOrReplace(@Nonnull TEntry newEntry) { } } - return insertHeadEntryFor(this.sizeTracker, this.buckets, newEntry.keyHash, newEntry); + return insertHeadEntryFor(this.sizeManager, this.buckets, newEntry.keyHash, newEntry); } /** @@ -536,15 +535,15 @@ public TEntry tryGetOrCreate( } } // Deliberately isFull() -> create -> increment, rather than the one-call tracked - // insertHeadEntryFor(sizeTracker, ...) that insert/tryInsertOrReplace use: `creator` runs + // 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 - // SizeTracker#tryReserve. - if (this.sizeTracker.isFull()) { + // SizeManager#tryReserve. + if (this.sizeManager.isFull()) { return null; } TEntry newEntry = creator.apply(key1, key2); insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); - this.sizeTracker.increment(); + this.sizeManager.increment(); return newEntry; } @@ -562,8 +561,7 @@ public void forEach(C context, @Nonnull BiConsumer sink) { Hashtable.drain(this.buckets, sink); - this.sizeTracker.reset(); + this.sizeManager.reset(); } /** @@ -583,7 +581,7 @@ public void drain(@Nonnull Consumer sink) { */ public void drain(C context, @Nonnull BiConsumer sink) { Hashtable.drain(this.buckets, context, sink); - this.sizeTracker.reset(); + this.sizeManager.reset(); } } @@ -657,7 +655,7 @@ public static Hashtable.Entry[] create(int buckets) { * 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 #createCappedTable} all size themselves this way). Pair with a {@link SizeTracker} of + * {@link #createCappedTable} 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) { @@ -746,24 +744,24 @@ public static void insertHeadEntryFor( /** * {@link #insertHeadEntryFor(Hashtable.Entry[], long, Hashtable.Entry)}, but folding in the * strict-cap check that every unconditional insert needs: reserves a slot from {@code - * sizeTracker} first, splicing {@code entry} in only if the reservation succeeds. Returns {@code - * false} (without touching {@code buckets}) once {@code sizeTracker} is at capacity. Lets a + * 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 sizeTracker} leads, per this class's parameter order for the size-tracked statics: + *

    {@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 SizeTracker sizeTracker, + @Nonnull SizeManager sizeManager, @Nonnull Hashtable.Entry[] buckets, long keyHash, @Nonnull Hashtable.Entry entry) { - if (!sizeTracker.tryReserve()) { + if (!sizeManager.tryReserve()) { return false; } insertHeadEntryFor(buckets, keyHash, entry); @@ -772,17 +770,17 @@ public static boolean insertHeadEntryFor( /** * Scans the bucket chain at {@code keyHash} for the first entry matching {@code matches}, unlinks - * it, decrements {@code sizeTracker}, and returns it -- or returns {@code null} (leaving {@code - * buckets} and {@code sizeTracker} untouched) if nothing in the chain matches. Mirrors {@link - * #insertHeadEntryFor(SizeTracker, Hashtable.Entry[], long, Hashtable.Entry)} on the removal + * 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 sizeTracker} leads for the same reason it does on the insert side. + *

    {@code sizeManager} leads for the same reason it does on the insert side. */ @Nullable public static TEntry removeMatching( - @Nonnull SizeTracker sizeTracker, + @Nonnull SizeManager sizeManager, @Nonnull Hashtable.Entry[] buckets, long keyHash, @Nonnull Predicate matches) { @@ -791,7 +789,7 @@ public static TEntry removeMatching( TEntry curEntry = iter.next(); if (matches.test(curEntry)) { iter.remove(); - sizeTracker.decrement(); + sizeManager.decrement(); return curEntry; } } @@ -856,7 +854,7 @@ MutatingTableIterator mutatingTableIterator(@Nonnull Hashtable.Entry[] b /** * 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 EvictionCursor} -- where one call drives {@code [cursor, + * 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. @@ -875,6 +873,19 @@ 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, @@ -901,18 +912,37 @@ public static void drain( } /** - * Tracks a live entry count against a fixed capacity. {@link D1} and {@link D2} use this - * internally for their strict entry-count cap; other composers of the static building blocks - * above -- those driving a {@code Hashtable.Entry[]} directly -- can reuse it instead of - * hand-rolling the same increment/decrement/cap-check bookkeeping. + * 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 SizeTracker { + public static final class SizeManager { private final int capacity; private int size; - public SizeTracker(int capacity) { + /** + * 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; + + public SizeManager(int capacity) { this.capacity = capacity; } @@ -932,14 +962,12 @@ public boolean isFull() { /** * 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) -- e.g. {@link - * D1#insert}. When building the entry is itself fallible (e.g. {@link D1#tryGetOrCreate}'s - * {@code creator}), check {@link #isFull()} first, do the fallible work, then call {@link - * #increment()} only once linking actually succeeds. + * 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} here is not a final refusal -- it's the caller's cue to either - * refuse the insert, or make room (e.g. evict a stale entry via {@link EvictionCursor}) and - * retry. + *

    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 boolean tryReserve() { if (isFull()) { @@ -949,6 +977,29 @@ public boolean tryReserve() { return true; } + /** + * {@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. + */ + public boolean tryReserveOrEvict( + @Nonnull Hashtable.Entry[] buckets, @Nonnull Predicate evictable) { + if (tryReserve()) { + return true; + } + if (evictOne(buckets, evictable) == null) { + return false; + } + // 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; @@ -959,30 +1010,20 @@ 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; } - } - - /** - * Resumable cursor for scanning a bucket array to evict entries under a caller-supplied {@link - * Predicate}, without repeatedly re-scanning the same already-checked prefix on a sustained - * eviction stream. - * - *

    Pairs with {@link SizeTracker}: when {@link SizeTracker#tryReserve()} refuses because the - * table is full, a composer can call {@link #evictOne} to make room and retry, or give up if - * nothing was evictable. - * - *

    Not thread-safe, matching the rest of this class. - */ - public static final class EvictionCursor { - private int cursor; /** - * Scans {@code buckets} for the first entry matching {@code evictable}, starting at the cursor - * and wrapping all the way around back to the cursor if needed. Unlinks and returns the evicted - * entry, resuming the next call's scan from just past it; returns {@code null} if no entry - * matched anywhere in the table. + * Scans {@code buckets} for the first entry matching {@code evictable}, starting where the last + * eviction left off and wrapping around if needed. Unlinks and returns the evicted entry, + * decrementing the count; returns {@code null} (count untouched) if nothing matched anywhere. + * + *

    Resuming from the previous position is what keeps a sustained eviction stream amortized: + * the worst case for a single call is still O(N) when nearly every entry is hot, but N + * evictions never re-scan the hot prefix more than twice. */ @Nullable public Entry evictOne( @@ -991,6 +1032,9 @@ public Entry evictOne( if (evicted == null && this.cursor != 0) { evicted = evictOneInRange(buckets, evictable, 0, this.cursor); } + if (evicted != null) { + this.size -= 1; + } return evicted; } @@ -1014,11 +1058,15 @@ private Entry evictOneInRange( } /** - * Unlinks every entry matching {@code evictable} in a single full pass over {@code buckets}, - * regardless of the cursor's current position, and returns how many were removed. Resets the - * cursor to the start, since a full pass leaves nothing later to resume from. + * 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. */ - public int drain( + public int evictAll( @Nonnull Hashtable.Entry[] buckets, @Nonnull Predicate evictable) { int count = 0; MutatingTableIterator iter = mutatingTableIterator(buckets); @@ -1029,41 +1077,35 @@ public int drain( count++; } } + this.size -= count; this.cursor = 0; return count; } - - public void reset() { - this.cursor = 0; - } } /** - * Bundles a bucket array together with a {@link SizeTracker} and {@link EvictionCursor} sized and - * matched to it, so a composer driving the static building blocks directly gets everything it - * needs to store from one factory call, instead of separately sizing an array and a tracker that - * must stay in sync with it. Same headroom idiom as {@link D1}/{@link D2}'s constructors: {@code - * capacity} is the strict cap on live entries, and the backing array is sized with load-factor - * headroom over it. + * Bundles a bucket array together with a {@link SizeManager} sized and matched to it, so a + * composer driving the static building blocks directly gets everything it needs to store from one + * factory call, instead of separately sizing an array and a manager that must stay in sync with + * it. Same headroom idiom as {@link D1}/{@link D2}'s constructors: {@code capacity} is the strict + * cap on live entries, and the backing array is sized with load-factor headroom over it. * *

    Store the pieces of this bundle into your own fields; nothing here is meant to be held onto * as a {@code Table} itself. */ public static final class Table { public final Hashtable.Entry[] buckets; - public final SizeTracker size; - public final EvictionCursor evictionCursor = new EvictionCursor(); + public final SizeManager sizeManager; - private Table(Hashtable.Entry[] buckets, int capacity) { + private Table(Hashtable.Entry[] buckets, int maxCapacity) { this.buckets = buckets; - this.size = new SizeTracker(capacity); + this.sizeManager = new SizeManager(maxCapacity); } } /** - * Creates a {@link Table}: a bucket array sized with load-factor headroom over {@code capacity}, - * paired with a {@link SizeTracker} capped at the strict {@code capacity} and a fresh {@link - * EvictionCursor}. + * Creates a {@link Table}: 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 Table createCappedTable(int maxCapacity) { 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 154093ba684..2c63afe9a3e 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -101,7 +101,7 @@ void capacityForRejectsLoadFactorOutOfRange() { @Test void insertHeadEntryForTracksSizeAndRefusesAtCapacity() { Hashtable.Entry[] buckets = Hashtable.create(2); - Hashtable.SizeTracker size = new Hashtable.SizeTracker(2); + Hashtable.SizeManager size = new Hashtable.SizeManager(2); StringIntEntry a = new StringIntEntry("a", 1); StringIntEntry b = new StringIntEntry("b", 2); @@ -120,7 +120,7 @@ void insertHeadEntryForTracksSizeAndRefusesAtCapacity() { @Test void removeMatchingUnlinksAndDecrements() { Hashtable.Entry[] buckets = Hashtable.create(8); - Hashtable.SizeTracker size = new Hashtable.SizeTracker(8); + Hashtable.SizeManager size = new Hashtable.SizeManager(8); StringIntEntry a = new StringIntEntry("a", 1); Hashtable.insertHeadEntryFor(size, buckets, a.keyHash, a); @@ -135,7 +135,7 @@ void removeMatchingUnlinksAndDecrements() { @Test void removeMatchingReturnsNullAndLeavesStateWhenNothingMatches() { Hashtable.Entry[] buckets = Hashtable.create(8); - Hashtable.SizeTracker size = new Hashtable.SizeTracker(8); + Hashtable.SizeManager size = new Hashtable.SizeManager(8); StringIntEntry a = new StringIntEntry("a", 1); Hashtable.insertHeadEntryFor(size, buckets, a.keyHash, a); @@ -157,7 +157,8 @@ void bucketIndexIsBoundedByArrayLength() { @Test void clearNullsAllBuckets() { - Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); + Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("x", 1); buckets[1] = new StringIntEntry("y", 2); Hashtable.clear(buckets); @@ -168,7 +169,8 @@ void clearNullsAllBuckets() { @Test void drainVisitsEveryEntryThenClears() { - Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); + Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("x", 1); buckets[1] = new StringIntEntry("y", 2); Set drained = new HashSet<>(); @@ -183,7 +185,8 @@ void drainVisitsEveryEntryThenClears() { @Test void insertHeadEntrySplicesAsNewHead() { - Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); + Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.Entry[] buckets = table.buckets; StringIntEntry a = new StringIntEntry("a", 1); StringIntEntry b = new StringIntEntry("b", 2); Hashtable.insertHeadEntryAt(buckets, 0, a); @@ -644,17 +647,18 @@ void currentBucketReportsLandingIndex() { } } - // ============ EvictionCursor ============ + // ============ Eviction (SizeManager) ============ @Nested - class EvictionCursorTests { + class EvictionTests { @Test void evictOneRemovesFirstMatchAndAdvancesCursor() { - Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); + Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("a", 1); buckets[1] = new StringIntEntry("b", 2); - Hashtable.EvictionCursor cursor = new Hashtable.EvictionCursor(); + Hashtable.SizeManager cursor = table.sizeManager; StringIntEntry evicted = (StringIntEntry) cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 2); @@ -666,9 +670,10 @@ void evictOneRemovesFirstMatchAndAdvancesCursor() { @Test void evictOneReturnsNullWhenNothingMatches() { - Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); + Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("a", 1); - Hashtable.EvictionCursor cursor = new Hashtable.EvictionCursor(); + Hashtable.SizeManager cursor = table.sizeManager; assertNull(cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 999)); assertNotNull(buckets[0]); @@ -676,10 +681,11 @@ void evictOneReturnsNullWhenNothingMatches() { @Test void evictOneWrapsAroundToStartOfTable() { - Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); + Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("a", 1); buckets[3] = new StringIntEntry("d", 4); - Hashtable.EvictionCursor cursor = new Hashtable.EvictionCursor(); + Hashtable.SizeManager cursor = table.sizeManager; // First eviction matches bucket 3, advancing the cursor there. StringIntEntry first = @@ -694,14 +700,15 @@ void evictOneWrapsAroundToStartOfTable() { @Test void drainRemovesAllMatchesAndResetsCursor() { - Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); + Hashtable.Table table = Hashtable.createCappedTable(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.EvictionCursor cursor = new Hashtable.EvictionCursor(); + Hashtable.SizeManager cursor = table.sizeManager; cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 3); - int removed = cursor.drain(buckets, e -> ((StringIntEntry) e).value < 3); + int removed = cursor.evictAll(buckets, e -> ((StringIntEntry) e).value < 3); assertEquals(2, removed); assertNull(buckets[0]); @@ -716,9 +723,10 @@ void drainRemovesAllMatchesAndResetsCursor() { @Test void resetZeroesCursor() { - Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 4); + Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.Entry[] buckets = table.buckets; buckets[3] = new StringIntEntry("d", 4); - Hashtable.EvictionCursor cursor = new Hashtable.EvictionCursor(); + Hashtable.SizeManager cursor = table.sizeManager; cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 4); cursor.reset(); @@ -742,29 +750,29 @@ void createTableSizesBucketsWithHeadroomAndCapsSize() { 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.size); - assertNotNull(table.evictionCursor); - assertEquals(4, table.size.capacity()); - assertFalse(table.size.isFull()); + assertNotNull(table.sizeManager); + assertNotNull(table.sizeManager); + assertEquals(4, table.sizeManager.capacity()); + assertFalse(table.sizeManager.isFull()); } @Test void tableSizeTrackerRespectsCapacity() { Hashtable.Table table = Hashtable.createCappedTable(1); - assertTrue(table.size.tryReserve()); - assertTrue(table.size.isFull()); - assertFalse(table.size.tryReserve()); + assertTrue(table.sizeManager.tryReserve()); + assertTrue(table.sizeManager.isFull()); + assertFalse(table.sizeManager.tryReserve()); } @Test - void tableEvictionCursorOperatesOnItsOwnBuckets() { + void tableSizeManagerOperatesOnItsOwnBuckets() { Hashtable.Table table = Hashtable.createCappedTable(4); table.buckets[0] = new StringIntEntry("a", 1); StringIntEntry evicted = (StringIntEntry) - table.evictionCursor.evictOne(table.buckets, e -> ((StringIntEntry) e).value == 1); + table.sizeManager.evictOne(table.buckets, e -> ((StringIntEntry) e).value == 1); assertEquals("a", evicted.key); assertNull(table.buckets[0]); From 1c370d96a0e2e92a2e2dea2e6efa78de7e733610 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 18:25:21 -0400 Subject: [PATCH 21/41] Rename Hashtable.Table to State and make it something you hold Table said what it was made of, not what it is for, and it competed with D1/D2 -- which are also tables. State says the honest thing: both halves are mutable, the spine holds the entries and the SizeManager holds how many there are and where the last eviction looked. Its javadoc previously told callers to unpack it into their own fields and not retain it. That was backwards. An array and a manager stored separately can drift apart, which is exactly what this type exists to prevent, so holding the pair is now the documented usage. createCappedTable becomes createCapped, matching D1/D2.createCapped and sitting alongside the raw create(int buckets) -- create allocates an array by bucket count, createCapped builds capped state from an entry count, consistent with the entries-vs-buckets split elsewhere. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/datadog/trace/util/Hashtable.java | 56 ++++++++++--------- .../datadog/trace/util/HashtableTest.java | 24 ++++---- 2 files changed, 42 insertions(+), 38 deletions(-) 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 8abb1e1c19d..34f3694b2ee 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -82,9 +82,9 @@ public final TEntry next() { * 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#createCappedTable(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. + * Hashtable#createCapped(int)} hands you a spine and a {@link SizeManager} already matched to + * each other, and the manager evicts as well as counts. Actual bucket-array length is rounded up + * to the next power of two. * *

    Null keys are permitted; they collapse to a single bucket via the sentinel hash {@link * Long#MIN_VALUE} defined in {@link D1.Entry#hash}. @@ -160,7 +160,7 @@ private D1(int maxCapacity) { * 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#createCappedTable(int)}) rather than reaching for an uncapped table. + * Hashtable#createCapped(int)}) rather than reaching for an uncapped table. * *

    Pick {@code maxCapacity} in the right ballpark of what you actually expect to hold * -- the bucket array is sized from it, so it is read as both the limit and a rough estimate. @@ -612,9 +612,9 @@ public void drain(C context, @Nonnull BiConsumer * *

    {@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 - * #createCappedTable} size themselves), pass {@link #capacityFor(int)} instead: {@code - * create(MyEntry.class, capacityFor(cardinalityLimit))}. + * chains stay short even as the table fills, the way {@link D1}/{@link D2}/{@link #createCapped} + * size themselves), pass {@link #capacityFor(int)} instead: {@code create(MyEntry.class, + * capacityFor(cardinalityLimit))}. */ @SuppressWarnings("unchecked") @Nonnull @@ -628,11 +628,11 @@ public static TEntry[] create( * rounded up to the next power of two, with the base {@code Hashtable.Entry[]} component type. * *

    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 - * #createCappedTable} 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 Hashtable.Entry[]} -- that is what {@link D1}, {@link D2}, and {@link #createCapped} + * allocate internally. Prefer {@link #create(Class, int)} when you own the array and want a real + * {@code TEntry} component type (typed reads, array-store checks, a monomorphic element type for + * the JIT); prefer this one when a typed spine would only buy you covariant array-store checks on + * every insert. Capacity is fixed; the table does not resize. * *

    {@code buckets} is a bucket count, not an entry cap -- see {@link #capacityFor(int)} to * derive one from a target cap on live entries. @@ -655,8 +655,8 @@ public static Hashtable.Entry[] create(int buckets) { * 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 #createCappedTable} all size themselves this way). Pair with a {@link SizeManager} of - * {@code cardinalityLimit} for the matching strict cap; this method only sizes the array. + * {@link #createCapped} all size themselves this way). Pair with a {@link SizeManager} of {@code + * cardinalityLimit} for the matching strict cap; this method only sizes the array. */ public static int capacityFor(int cardinalityLimit) { return capacityFor(cardinalityLimit, DEFAULT_LOAD_FACTOR); @@ -1084,33 +1084,37 @@ public int evictAll( } /** - * Bundles a bucket array together with a {@link SizeManager} sized and matched to it, so a - * composer driving the static building blocks directly gets everything it needs to store from one - * factory call, instead of separately sizing an array and a manager that must stay in sync with - * it. Same headroom idiom as {@link D1}/{@link D2}'s constructors: {@code capacity} is the strict - * cap on live entries, and the backing array is sized with load-factor headroom over it. + * 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. * - *

    Store the pieces of this bundle into your own fields; nothing here is meant to be held onto - * as a {@code Table} itself. + *

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

    Same headroom idiom as {@link D1}/{@link D2}: {@code maxCapacity} is the strict cap on live + * entries, and the backing array is sized with load-factor headroom over it. */ - public static final class Table { + public static final class State { public final Hashtable.Entry[] buckets; public final SizeManager sizeManager; - private Table(Hashtable.Entry[] buckets, int maxCapacity) { + private State(Hashtable.Entry[] buckets, int maxCapacity) { this.buckets = buckets; this.sizeManager = new SizeManager(maxCapacity); } } /** - * Creates a {@link Table}: a bucket array sized with load-factor headroom over {@code + * 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 Table createCappedTable(int maxCapacity) { + public static State createCapped(int maxCapacity) { Hashtable.Entry[] buckets = create(capacityFor(maxCapacity)); - return new Table(buckets, maxCapacity); + return new State(buckets, maxCapacity); } /** 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 2c63afe9a3e..a1c6045aec8 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -157,7 +157,7 @@ void bucketIndexIsBoundedByArrayLength() { @Test void clearNullsAllBuckets() { - Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("x", 1); buckets[1] = new StringIntEntry("y", 2); @@ -169,7 +169,7 @@ void clearNullsAllBuckets() { @Test void drainVisitsEveryEntryThenClears() { - Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("x", 1); buckets[1] = new StringIntEntry("y", 2); @@ -185,7 +185,7 @@ void drainVisitsEveryEntryThenClears() { @Test void insertHeadEntrySplicesAsNewHead() { - Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; StringIntEntry a = new StringIntEntry("a", 1); StringIntEntry b = new StringIntEntry("b", 2); @@ -654,7 +654,7 @@ class EvictionTests { @Test void evictOneRemovesFirstMatchAndAdvancesCursor() { - Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("a", 1); buckets[1] = new StringIntEntry("b", 2); @@ -670,7 +670,7 @@ void evictOneRemovesFirstMatchAndAdvancesCursor() { @Test void evictOneReturnsNullWhenNothingMatches() { - Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("a", 1); Hashtable.SizeManager cursor = table.sizeManager; @@ -681,7 +681,7 @@ void evictOneReturnsNullWhenNothingMatches() { @Test void evictOneWrapsAroundToStartOfTable() { - Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("a", 1); buckets[3] = new StringIntEntry("d", 4); @@ -700,7 +700,7 @@ void evictOneWrapsAroundToStartOfTable() { @Test void drainRemovesAllMatchesAndResetsCursor() { - Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("a", 1); buckets[1] = new StringIntEntry("b", 2); @@ -723,7 +723,7 @@ void drainRemovesAllMatchesAndResetsCursor() { @Test void resetZeroesCursor() { - Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; buckets[3] = new StringIntEntry("d", 4); Hashtable.SizeManager cursor = table.sizeManager; @@ -741,11 +741,11 @@ void resetZeroesCursor() { // ============ Table ============ @Nested - class TableTests { + class StateTests { @Test void createTableSizesBucketsWithHeadroomAndCapsSize() { - Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.State table = Hashtable.createCapped(4); int len = table.buckets.length; assertTrue(len >= 4, "backing array must have load-factor headroom over capacity"); @@ -758,7 +758,7 @@ void createTableSizesBucketsWithHeadroomAndCapsSize() { @Test void tableSizeTrackerRespectsCapacity() { - Hashtable.Table table = Hashtable.createCappedTable(1); + Hashtable.State table = Hashtable.createCapped(1); assertTrue(table.sizeManager.tryReserve()); assertTrue(table.sizeManager.isFull()); @@ -767,7 +767,7 @@ void tableSizeTrackerRespectsCapacity() { @Test void tableSizeManagerOperatesOnItsOwnBuckets() { - Hashtable.Table table = Hashtable.createCappedTable(4); + Hashtable.State table = Hashtable.createCapped(4); table.buckets[0] = new StringIntEntry("a", 1); StringIntEntry evicted = From c89311761a8e329eae3935d143fcf31a0e092619 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 18:34:37 -0400 Subject: [PATCH 22/41] Take State in the size-tracked statics; keep eviction static too State is parameterized on its entry type, so the statics that need both the spine and its manager take one argument instead of two: insertHeadEntryFor(state, keyHash, entry) removeMatching(state, keyHash, matches) clear(state) tryReserveOrEvict(state, evictable) evictOne(state, evictable) / evictAll(state, evictable) Two things this buys beyond brevity. A manager belonging to a different table is no longer passable -- the pairing is structural rather than a convention the caller upholds. And TEntry now has somewhere to be inferred from, which removes both warts the client-side-stats migration hit: the explicit Hashtable.removeMatching witness, and the cast inside eviction predicates, which are now typed to the entry. Eviction stays static rather than moving onto State, even though State is the thing holding the cursor underneath. Composition through static functions over caller-owned data is the shape of this class, and keeping it means a caller gets the cursor-resumed scan -- and its amortization across a sustained eviction stream -- without knowing a cursor exists. State itself stays pure data: two final fields, no behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/datadog/trace/util/Hashtable.java | 88 +++++++++++-- .../datadog/trace/util/HashtableTest.java | 120 +++++++++++++----- 2 files changed, 160 insertions(+), 48 deletions(-) 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 34f3694b2ee..5849dafac60 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -768,6 +768,17 @@ public static boolean insertHeadEntryFor( 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 @@ -796,6 +807,42 @@ public static TEntry removeMatching( return null; } + /** + * Reserves a slot in {@code state} for a fresh insert, evicting one entry matching {@code + * evictable} if the table is full. {@code false} means full with nothing evictable -- the caller + * should drop the datum. The whole capacity decision of a self-evicting table's miss path in one + * call; pass a non-capturing {@code evictable} (typically a {@code static final}) to keep it + * allocation-free. + */ + public static boolean tryReserveOrEvict( + @Nonnull State state, @Nonnull Predicate evictable) { + return state.sizeManager.tryReserveOrEvict(state.buckets, evictable); + } + + /** + * Unlinks the first entry in {@code state} matching {@code evictable}, resuming from where the + * last eviction looked, and decrements the count. {@code null} if nothing matched anywhere. + */ + @Nullable + public static TEntry evictOne( + @Nonnull State state, @Nonnull Predicate evictable) { + return state.sizeManager.evictOne(state.buckets, evictable); + } + + /** + * Unlinks every entry in {@code state} matching {@code evictable}, decrementing per removal, and + * returns how many went. + */ + public static int evictAll( + @Nonnull State state, @Nonnull Predicate evictable) { + return state.sizeManager.evictAll(state.buckets, evictable); + } + + /** {@link #clear(SizeManager, Hashtable.Entry[])} over a {@link State}. */ + public static void clear(@Nonnull State state) { + clear(state.sizeManager, state.buckets); + } + /** * Walks every entry in {@code buckets} and invokes {@code consumer} on it. The unchecked cast to * {@code TEntry} lives here (mirroring {@link Entry#next()}) so callers don't have to sprinkle it @@ -869,6 +916,16 @@ MutatingTableIterator mutatingTableIterator( return new MutatingTableIterator(buckets, startBucket, endBucket); } + /** + * {@link #removeMatching(SizeManager, Hashtable.Entry[], long, Predicate)} over a {@link State}. + * The predicate is typed to {@code TEntry}, so a caller matching on entry fields needs no cast. + */ + @Nullable + public static TEntry removeMatching( + @Nonnull State state, long keyHash, @Nonnull Predicate matches) { + return removeMatching(state.sizeManager, state.buckets, keyHash, matches); + } + public static void clear(@Nonnull Hashtable.Entry[] buckets) { Arrays.fill(buckets, null); } @@ -987,8 +1044,8 @@ public boolean tryReserve() { * non-capturing {@code evictable} (typically a {@code static final}) to keep it * allocation-free. */ - public boolean tryReserveOrEvict( - @Nonnull Hashtable.Entry[] buckets, @Nonnull Predicate evictable) { + public boolean tryReserveOrEvict( + @Nonnull Hashtable.Entry[] buckets, @Nonnull Predicate evictable) { if (tryReserve()) { return true; } @@ -1025,9 +1082,10 @@ public void reset() { * the worst case for a single call is still O(N) when nearly every entry is hot, but N * evictions never re-scan the hot prefix more than twice. */ + @SuppressWarnings("unchecked") @Nullable - public Entry evictOne( - @Nonnull Hashtable.Entry[] buckets, @Nonnull Predicate evictable) { + public TEntry evictOne( + @Nonnull Hashtable.Entry[] buckets, @Nonnull Predicate evictable) { Entry evicted = evictOneInRange(buckets, evictable, this.cursor, buckets.length); if (evicted == null && this.cursor != 0) { evicted = evictOneInRange(buckets, evictable, 0, this.cursor); @@ -1035,19 +1093,20 @@ public Entry evictOne( if (evicted != null) { this.size -= 1; } - return evicted; + return (TEntry) evicted; } + @SuppressWarnings("unchecked") @Nullable - private Entry evictOneInRange( + private Entry evictOneInRange( @Nonnull Hashtable.Entry[] buckets, - @Nonnull Predicate evictable, + @Nonnull Predicate evictable, int startBucket, int endBucket) { MutatingTableIterator iter = mutatingTableIterator(buckets, startBucket, endBucket); while (iter.hasNext()) { Entry candidate = iter.next(); - if (evictable.test(candidate)) { + if (evictable.test((TEntry) candidate)) { int bucket = iter.currentBucket(); iter.remove(); this.cursor = bucket; @@ -1066,13 +1125,14 @@ private Entry evictOneInRange( * 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. */ - public int evictAll( - @Nonnull Hashtable.Entry[] buckets, @Nonnull Predicate evictable) { + @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(candidate)) { + if (evictable.test((TEntry) candidate)) { iter.remove(); count++; } @@ -1097,7 +1157,7 @@ public int evictAll( *

    Same headroom idiom as {@link D1}/{@link D2}: {@code maxCapacity} is the strict cap on live * entries, and the backing array is sized with load-factor headroom over it. */ - public static final class State { + public static final class State { public final Hashtable.Entry[] buckets; public final SizeManager sizeManager; @@ -1112,9 +1172,9 @@ private State(Hashtable.Entry[] buckets, int maxCapacity) { * maxCapacity}, paired with a {@link SizeManager} capped at the strict {@code maxCapacity}. */ @Nonnull - public static State createCapped(int maxCapacity) { + public static State createCapped(int maxCapacity) { Hashtable.Entry[] buckets = create(capacityFor(maxCapacity)); - return new State(buckets, maxCapacity); + return new State<>(buckets, maxCapacity); } /** 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 a1c6045aec8..497fdefd5d8 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -157,7 +157,7 @@ void bucketIndexIsBoundedByArrayLength() { @Test void clearNullsAllBuckets() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("x", 1); buckets[1] = new StringIntEntry("y", 2); @@ -169,7 +169,7 @@ void clearNullsAllBuckets() { @Test void drainVisitsEveryEntryThenClears() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("x", 1); buckets[1] = new StringIntEntry("y", 2); @@ -185,7 +185,7 @@ void drainVisitsEveryEntryThenClears() { @Test void insertHeadEntrySplicesAsNewHead() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; StringIntEntry a = new StringIntEntry("a", 1); StringIntEntry b = new StringIntEntry("b", 2); @@ -654,61 +654,116 @@ class EvictionTests { @Test void evictOneRemovesFirstMatchAndAdvancesCursor() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("a", 1); buckets[1] = new StringIntEntry("b", 2); - Hashtable.SizeManager cursor = table.sizeManager; - - StringIntEntry evicted = - (StringIntEntry) cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 2); + StringIntEntry evicted = Hashtable.evictOne(table, e -> e.value == 2); assertEquals("b", evicted.key); assertNull(buckets[1]); assertNotNull(buckets[0]); } + @Test + void tryReserveOrEvictReservesWhileRoomRemains() { + Hashtable.State table = Hashtable.createCapped(2); + + assertTrue(Hashtable.tryReserveOrEvict(table, e -> e.value == 0)); + assertTrue(Hashtable.tryReserveOrEvict(table, e -> e.value == 0)); + assertEquals(2, table.sizeManager.size()); + } + + @Test + void tryReserveOrEvictMakesRoomWhenFull() { + Hashtable.State table = Hashtable.createCapped(2); + StringIntEntry stale = new StringIntEntry("stale", 0); + StringIntEntry hot = new StringIntEntry("hot", 1); + assertTrue(Hashtable.insertHeadEntryFor(table, stale.keyHash, stale)); + assertTrue(Hashtable.insertHeadEntryFor(table, hot.keyHash, hot)); + assertTrue(table.sizeManager.isFull()); + + // Full, but one entry is evictable -- the slot it frees becomes the reservation. + assertTrue(Hashtable.tryReserveOrEvict(table, e -> e.value == 0)); + assertEquals(2, table.sizeManager.size(), "one out, one reserved"); + Set remaining = new HashSet<>(); + Hashtable.forEach(table.buckets, e -> remaining.add(e.key)); + assertFalse(remaining.contains("stale"), "the evictable entry is gone"); + assertTrue(remaining.contains("hot"), "the hot entry survived"); + } + + @Test + void tryReserveOrEvictRefusesWhenFullAndNothingEvictable() { + Hashtable.State table = Hashtable.createCapped(1); + StringIntEntry hot = new StringIntEntry("hot", 1); + assertTrue(Hashtable.insertHeadEntryFor(table, hot.keyHash, hot)); + + assertFalse(Hashtable.tryReserveOrEvict(table, e -> e.value == 0)); + assertEquals(1, table.sizeManager.size(), "a refused reservation consumes nothing"); + Set remaining = new HashSet<>(); + Hashtable.forEach(table.buckets, e -> remaining.add(e.key)); + assertTrue(remaining.contains("hot"), "nothing was evicted"); + } + + @Test + void removeMatchingOverStateNeedsNoTypeWitness() { + Hashtable.State table = Hashtable.createCapped(4); + StringIntEntry a = new StringIntEntry("a", 1); + Hashtable.insertHeadEntryFor(table, a.keyHash, a); + + StringIntEntry removed = Hashtable.removeMatching(table, a.keyHash, e -> e.matches("a")); + + assertSame(a, removed); + assertEquals(0, table.sizeManager.size()); + } + + @Test + void clearOverStateEmptiesSpineAndResetsCount() { + Hashtable.State table = Hashtable.createCapped(4); + StringIntEntry a = new StringIntEntry("a", 1); + Hashtable.insertHeadEntryFor(table, a.keyHash, a); + assertEquals(1, table.sizeManager.size()); + + Hashtable.clear(table); + + assertEquals(0, table.sizeManager.size()); + assertNull(table.buckets[Hashtable.bucketIndex(table.buckets, a.keyHash)]); + } + @Test void evictOneReturnsNullWhenNothingMatches() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("a", 1); - Hashtable.SizeManager cursor = table.sizeManager; - - assertNull(cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 999)); + assertNull(Hashtable.evictOne(table, e -> e.value == 999)); assertNotNull(buckets[0]); } @Test void evictOneWrapsAroundToStartOfTable() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("a", 1); buckets[3] = new StringIntEntry("d", 4); - Hashtable.SizeManager cursor = table.sizeManager; - // First eviction matches bucket 3, advancing the cursor there. - StringIntEntry first = - (StringIntEntry) cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 4); + 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 = - (StringIntEntry) cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 1); + StringIntEntry second = Hashtable.evictOne(table, e -> e.value == 1); assertEquals("a", second.key); } @Test void drainRemovesAllMatchesAndResetsCursor() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("a", 1); buckets[1] = new StringIntEntry("b", 2); buckets[2] = new StringIntEntry("c", 3); - Hashtable.SizeManager cursor = table.sizeManager; - cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 3); + Hashtable.evictOne(table, e -> e.value == 3); - int removed = cursor.evictAll(buckets, e -> ((StringIntEntry) e).value < 3); + int removed = Hashtable.evictAll(table, e -> e.value < 3); assertEquals(2, removed); assertNull(buckets[0]); @@ -716,24 +771,21 @@ void drainRemovesAllMatchesAndResetsCursor() { // drain resets the cursor to the start, so a fresh scan finds bucket 0 without wrapping. buckets[0] = new StringIntEntry("a2", 1); - StringIntEntry evicted = - (StringIntEntry) cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 1); + StringIntEntry evicted = Hashtable.evictOne(table, e -> e.value == 1); assertEquals("a2", evicted.key); } @Test void resetZeroesCursor() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createCapped(4); Hashtable.Entry[] buckets = table.buckets; buckets[3] = new StringIntEntry("d", 4); - Hashtable.SizeManager cursor = table.sizeManager; - cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 4); + Hashtable.evictOne(table, e -> e.value == 4); - cursor.reset(); + table.sizeManager.reset(); buckets[0] = new StringIntEntry("a", 1); - StringIntEntry evicted = - (StringIntEntry) cursor.evictOne(buckets, e -> ((StringIntEntry) e).value == 1); + StringIntEntry evicted = Hashtable.evictOne(table, e -> e.value == 1); assertEquals("a", evicted.key); } } @@ -745,7 +797,7 @@ class StateTests { @Test void createTableSizesBucketsWithHeadroomAndCapsSize() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createCapped(4); int len = table.buckets.length; assertTrue(len >= 4, "backing array must have load-factor headroom over capacity"); @@ -758,7 +810,7 @@ void createTableSizesBucketsWithHeadroomAndCapsSize() { @Test void tableSizeTrackerRespectsCapacity() { - Hashtable.State table = Hashtable.createCapped(1); + Hashtable.State table = Hashtable.createCapped(1); assertTrue(table.sizeManager.tryReserve()); assertTrue(table.sizeManager.isFull()); @@ -767,7 +819,7 @@ void tableSizeTrackerRespectsCapacity() { @Test void tableSizeManagerOperatesOnItsOwnBuckets() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createCapped(4); table.buckets[0] = new StringIntEntry("a", 1); StringIntEntry evicted = From 050c304f165e83610e02870f6f3c286c8b60e6ff Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 18:54:46 -0400 Subject: [PATCH 23/41] Round out the State-taking statics: size, isEmpty, bucketFor, forEach From reviewing the first real consumer (#12312), where each of these was either reaching into state.buckets or reaching into state.sizeManager to do something the API should have offered directly: size(state) / isEmpty(state) bucketFor(state, keyHash) -- typed, so the chain walk needs no witness forEach(state, consumer) -- and the context-passing overload Also adds insertReserved(state, keyHash, entry), which links an entry without touching the count because the caller already holds a reservation. That is the other half of tryReserveOrEvict, and it is deliberately a different name from insertHeadEntryFor(State, ...) -- that one reserves as it inserts, so using it after a reservation would count the entry twice. Splitting them keeps the refuse-before-you- allocate shape available: reserve, and only build the entry once the slot is yours. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/datadog/trace/util/Hashtable.java | 55 +++++++++++++++++++ .../datadog/trace/util/HashtableTest.java | 25 +++++++++ 2 files changed, 80 insertions(+) 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 5849dafac60..928235eae93 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -807,6 +807,61 @@ public static TEntry removeMatching( return null; } + /** Live entry count of {@code state}. */ + public static int size(@Nonnull State state) { + return state.sizeManager.size(); + } + + /** {@code true} when {@code state} holds no entries. */ + public static boolean isEmpty(@Nonnull State state) { + return state.sizeManager.size() == 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 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 497fdefd5d8..839c4094952 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -717,6 +717,31 @@ void removeMatchingOverStateNeedsNoTypeWitness() { assertEquals(0, table.sizeManager.size()); } + @Test + void stateAccessorsAndInsertReservedRoundTrip() { + Hashtable.State table = Hashtable.createCapped(4); + assertTrue(Hashtable.isEmpty(table)); + assertEquals(0, Hashtable.size(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.size(table), "insertReserved must not count the entry twice"); + assertFalse(Hashtable.isEmpty(table)); + assertSame(a, Hashtable.bucketFor(table, a.keyHash), "typed, no witness needed"); + + Set seen = new HashSet<>(); + Hashtable.forEach(table, e -> seen.add(e.key)); + assertEquals(1, seen.size()); + assertTrue(seen.contains("a")); + + Set viaContext = new HashSet<>(); + Hashtable.forEach(table, viaContext, (ctx, e) -> ctx.add(e.key)); + assertTrue(viaContext.contains("a")); + } + @Test void clearOverStateEmptiesSpineAndResetsCount() { Hashtable.State table = Hashtable.createCapped(4); From 33e9f4ffa2c88b95fa678a8bb63ab16f374c350c Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 19:20:33 -0400 Subject: [PATCH 24/41] Add size-tracked drain; fix two review nits From /techdebt and /perf-review over the branch. drain was the one size-tracked pair still left to the caller. clear gained a (sizeManager, buckets) form whose javadoc says emptying without resetting "leaves the cap permanently consumed, so the two belong in one call" -- and then D1.drain and D2.drain did exactly that pair by hand, and a composer calling the public static against a State would have leaked the cap silently. Adds drain(sizeManager, buckets, sink), the context-passing form, and both State overloads; D1/D2 route through them. Also repairs a comment in CaseInsensitiveMapBenchmark that a rename reflow had mangled mid-sentence, and records why the D1/D2 benchmarks use @Setup(Level.Iteration) rather than Trial -- the setup rebuilds the table and the HashMap, so iterations must not inherit mutated counters; the pollution call merely rides along. Co-Authored-By: Claude Opus 5 (1M context) --- .../util/CaseInsensitiveMapBenchmark.java | 8 +-- .../trace/util/HashtableD1Benchmark.java | 3 ++ .../trace/util/HashtableD2Benchmark.java | 3 ++ .../java/datadog/trace/util/Hashtable.java | 50 ++++++++++++++++--- 4 files changed, 52 insertions(+), 12 deletions(-) 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 2b4c2e79f57..ec067cefce2 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java @@ -274,10 +274,10 @@ static CIEntry[] _create_flat(float loadFactor) { } } // Mirror the HashMap/TreeMap builds' second loop (UPPER_PREFIXES, suffix 0 & 2): 8 case- - // insensitive collisions. tryGetOrCreate 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. 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 ac22417c597..6efea71c1a9 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java @@ -143,6 +143,9 @@ 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(); 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 bed64d7a613..4f233b8524b 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java @@ -179,6 +179,9 @@ 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(); 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 928235eae93..720bef3b228 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -334,8 +334,7 @@ public void clear() { * emitter, etc.). Equivalent to {@link #forEach} then {@link #clear} in a single call. */ public void drain(@Nonnull Consumer sink) { - Hashtable.drain(this.buckets, sink); - this.sizeManager.reset(); + Hashtable.drain(this.sizeManager, this.buckets, sink); } /** @@ -344,8 +343,7 @@ public void drain(@Nonnull Consumer sink) { * allocation. */ public void drain(C context, @Nonnull BiConsumer sink) { - Hashtable.drain(this.buckets, context, sink); - this.sizeManager.reset(); + Hashtable.drain(this.sizeManager, this.buckets, context, sink); } } @@ -570,8 +568,7 @@ public void clear() { * emitter, etc.). Equivalent to {@link #forEach} then {@link #clear} in a single call. */ public void drain(@Nonnull Consumer sink) { - Hashtable.drain(this.buckets, sink); - this.sizeManager.reset(); + Hashtable.drain(this.sizeManager, this.buckets, sink); } /** @@ -580,8 +577,7 @@ public void drain(@Nonnull Consumer sink) { * allocation. */ public void drain(C context, @Nonnull BiConsumer sink) { - Hashtable.drain(this.buckets, context, sink); - this.sizeManager.reset(); + Hashtable.drain(this.sizeManager, this.buckets, context, sink); } } @@ -807,6 +803,44 @@ public static TEntry removeMatching( return null; } + /** + * {@link #drain(Hashtable.Entry[], Consumer)} plus the matching bookkeeping: empties the table + * into {@code sink} and resets {@code sizeManager} to zero. Draining without resetting leaves the + * cap permanently consumed, so the two belong in one call rather than as a pair the caller has to + * remember -- same reasoning as {@link #clear(SizeManager, Hashtable.Entry[])}. + */ + public static void drain( + @Nonnull SizeManager sizeManager, + @Nonnull Hashtable.Entry[] buckets, + @Nonnull Consumer sink) { + Hashtable.drain(buckets, sink); + sizeManager.reset(); + } + + /** Context-passing form of {@link #drain(SizeManager, Hashtable.Entry[], Consumer)}. */ + public static void drain( + @Nonnull SizeManager sizeManager, + @Nonnull Hashtable.Entry[] buckets, + C context, + @Nonnull BiConsumer sink) { + Hashtable.drain(buckets, context, sink); + sizeManager.reset(); + } + + /** {@link #drain(SizeManager, Hashtable.Entry[], Consumer)} over a {@link State}. */ + public static void drain( + @Nonnull State state, @Nonnull Consumer sink) { + drain(state.sizeManager, state.buckets, sink); + } + + /** Context-passing form of {@link #drain(State, Consumer)}. */ + public static void drain( + @Nonnull State state, + C context, + @Nonnull BiConsumer sink) { + drain(state.sizeManager, state.buckets, context, sink); + } + /** Live entry count of {@code state}. */ public static int size(@Nonnull State state) { return state.sizeManager.size(); From 4c4509d565b6163ae25725f1fd4860278bf95d83 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 20:05:18 -0400 Subject: [PATCH 25/41] Step the eviction cursor on a failed scan; name the count honestly Eviction only advanced the cursor on a successful match, so a table that was full and entirely hot retried from the same origin on every miss, re-testing the same entries in the same order. It now steps on regardless. That does not shrink the per-attempt cost -- a scan that matches nothing has by definition tested every live entry -- so the javadoc now says so. It previously advertised only the amortized success case ("N evictions never re-scan the hot prefix more than twice"), which is true of successes and quietly untrue of refusals. Callers get told to size the cap to the steady-state working set and keep the predicate cheap, since it runs once per live entry on every refusal. Renames the count to match what it can promise. SizeManager.estimateSize is an estimate because reservations are counted the moment they are taken: between reserving and linking it reads one high, and insertReserved trusts the caller, so a link without a reservation reads low. Hashtable.isEmpty becomes isLikelyEmpty for the same reason -- fine for skipping work that would be wasted on an empty table, not for establishing that the table is empty. D1/D2 keep an exact size(): they reserve and link inside one call, so the window is never observable from outside. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/datadog/trace/util/Hashtable.java | 59 +++++++++++++++---- .../datadog/trace/util/HashtableTest.java | 46 ++++++++++----- 2 files changed, 80 insertions(+), 25 deletions(-) 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 720bef3b228..b8a8035df24 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -183,8 +183,12 @@ public static > D1 createCapped( 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.sizeManager.size(); + return this.sizeManager.estimateSize(); } /** {@code true} once {@link #size()} has reached this table's fixed capacity. */ @@ -448,8 +452,12 @@ public static > D2 creat 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.sizeManager.size(); + return this.sizeManager.estimateSize(); } /** {@code true} once {@link #size()} has reached this table's fixed capacity. */ @@ -841,14 +849,20 @@ public static void drain( drain(state.sizeManager, state.buckets, context, sink); } - /** Live entry count of {@code state}. */ - public static int size(@Nonnull State state) { - return state.sizeManager.size(); + /** 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} holds no entries. */ - public static boolean isEmpty(@Nonnull State state) { - return state.sizeManager.size() == 0; + /** + * {@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; } /** @@ -1092,7 +1106,17 @@ public SizeManager(int capacity) { this.capacity = capacity; } - public int size() { + /** + * 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. + * + *

    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 int estimateSize() { return this.size; } @@ -1169,7 +1193,14 @@ public void reset() { * *

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

    That amortization covers successes only. A call that matches nothing has, by + * definition, tested every live entry -- so a table that is full and entirely hot pays a full + * pass per attempt. The cursor still steps on, so repeated refusals at least start from a + * different bucket rather than re-testing in identical order, but the per-attempt cost does not + * shrink. Size the cap to the steady-state working set so this stays the rare path, and keep + * {@code evictable} cheap -- it is called once per live entry on every refusal. */ @SuppressWarnings("unchecked") @Nullable @@ -1181,8 +1212,14 @@ public TEntry evictOne( } if (evicted != null) { this.size -= 1; + return (TEntry) evicted; } - return (TEntry) evicted; + // Nothing matched anywhere. Step the cursor on regardless, so a table that is full of hot + // entries doesn't retry from the same origin every time -- successive refusals sweep a + // different starting bucket instead of re-testing the same entries in the same order. + // (buckets.length is a power of two, so the mask wraps.) + this.cursor = (this.cursor + 1) & (buckets.length - 1); + return null; } @SuppressWarnings("unchecked") 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 839c4094952..ce02a136cd6 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -109,12 +109,12 @@ void insertHeadEntryForTracksSizeAndRefusesAtCapacity() { assertTrue(Hashtable.insertHeadEntryFor(size, buckets, a.keyHash, a)); assertTrue(Hashtable.insertHeadEntryFor(size, buckets, b.keyHash, b)); - assertEquals(2, size.size()); + assertEquals(2, size.estimateSize()); assertFalse( Hashtable.insertHeadEntryFor(size, buckets, c.keyHash, c), "refused once the tracker is at capacity"); - assertEquals(2, size.size(), "a refused insert must not consume a slot"); + assertEquals(2, size.estimateSize(), "a refused insert must not consume a slot"); } @Test @@ -128,7 +128,7 @@ void removeMatchingUnlinksAndDecrements() { Hashtable.removeMatching(size, buckets, a.keyHash, e -> e.matches("a")); assertSame(a, removed); - assertEquals(0, size.size()); + assertEquals(0, size.estimateSize()); assertNull(Hashtable.bucketFor(buckets, a.keyHash)); } @@ -142,7 +142,7 @@ void removeMatchingReturnsNullAndLeavesStateWhenNothingMatches() { assertNull( Hashtable.removeMatching( size, buckets, a.keyHash, e -> e.matches("nope"))); - assertEquals(1, size.size(), "a non-matching scan must not decrement"); + assertEquals(1, size.estimateSize(), "a non-matching scan must not decrement"); assertSame(a, Hashtable.bucketFor(buckets, a.keyHash)); } @@ -671,7 +671,7 @@ void tryReserveOrEvictReservesWhileRoomRemains() { assertTrue(Hashtable.tryReserveOrEvict(table, e -> e.value == 0)); assertTrue(Hashtable.tryReserveOrEvict(table, e -> e.value == 0)); - assertEquals(2, table.sizeManager.size()); + assertEquals(2, table.sizeManager.estimateSize()); } @Test @@ -685,7 +685,7 @@ void tryReserveOrEvictMakesRoomWhenFull() { // 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.size(), "one out, one reserved"); + 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"); @@ -699,7 +699,7 @@ void tryReserveOrEvictRefusesWhenFullAndNothingEvictable() { assertTrue(Hashtable.insertHeadEntryFor(table, hot.keyHash, hot)); assertFalse(Hashtable.tryReserveOrEvict(table, e -> e.value == 0)); - assertEquals(1, table.sizeManager.size(), "a refused reservation consumes nothing"); + 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"); @@ -714,22 +714,23 @@ void removeMatchingOverStateNeedsNoTypeWitness() { StringIntEntry removed = Hashtable.removeMatching(table, a.keyHash, e -> e.matches("a")); assertSame(a, removed); - assertEquals(0, table.sizeManager.size()); + assertEquals(0, table.sizeManager.estimateSize()); } @Test void stateAccessorsAndInsertReservedRoundTrip() { Hashtable.State table = Hashtable.createCapped(4); - assertTrue(Hashtable.isEmpty(table)); - assertEquals(0, Hashtable.size(table)); + 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.size(table), "insertReserved must not count the entry twice"); - assertFalse(Hashtable.isEmpty(table)); + 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<>(); @@ -747,11 +748,11 @@ void clearOverStateEmptiesSpineAndResetsCount() { Hashtable.State table = Hashtable.createCapped(4); StringIntEntry a = new StringIntEntry("a", 1); Hashtable.insertHeadEntryFor(table, a.keyHash, a); - assertEquals(1, table.sizeManager.size()); + assertEquals(1, table.sizeManager.estimateSize()); Hashtable.clear(table); - assertEquals(0, table.sizeManager.size()); + assertEquals(0, table.sizeManager.estimateSize()); assertNull(table.buckets[Hashtable.bucketIndex(table.buckets, a.keyHash)]); } @@ -764,6 +765,23 @@ void evictOneReturnsNullWhenNothingMatches() { assertNotNull(buckets[0]); } + @Test + void evictOneAdvancesCursorEvenWhenNothingMatches() { + Hashtable.State table = Hashtable.createCapped(4); + StringIntEntry a = new StringIntEntry("a", 1); + assertTrue(Hashtable.insertHeadEntryFor(table, a.keyHash, a)); + + // Nothing is evictable, so the scan fails -- but the cursor must still move on. + assertNull(Hashtable.evictOne(table, e -> e.value == 999)); + assertEquals(1, Hashtable.estimateSize(table), "a failed scan evicts nothing"); + + // The cursor has stepped past where the entry sits, so finding it again needs the + // wrap-around pass; that it is still found proves the step did not strand it. + StringIntEntry evicted = Hashtable.evictOne(table, e -> e.value == 1); + assertEquals("a", evicted.key); + assertEquals(0, Hashtable.estimateSize(table)); + } + @Test void evictOneWrapsAroundToStartOfTable() { Hashtable.State table = Hashtable.createCapped(4); From 39091844b67c7fdc4bdbfa2ae753b996578fd12f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 20:14:04 -0400 Subject: [PATCH 26/41] Fix two eviction/drain defects found by Codex review evictAll subtracted its running count after the loop, so a predicate that threw part way through left the already-unlinked entries gone from the chains while the count kept counting them -- permanently high, which in a capped table means it eventually stops accepting anything. Now decrements per removal, matching evictOne. drain handed entries to the sink with their `next` links intact, since it was forEach followed by Arrays.fill. A sink that retained one entry of a chain pinned every entry behind it, including ones it had chosen to drop. Now a single pass that nulls the bucket slot and unhooks each entry before handing it over -- reading `next` first, because the sink may do anything with the entry once it has it. That also drops the second pass. Both come with regression tests, verified to fail against the pre-fix code: one drives evictAll with a throwing predicate and asserts the count matches the spine, the other drains a forced collision chain and asserts the drained entries are detached. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/datadog/trace/util/Hashtable.java | 34 +++++++++++-- .../datadog/trace/util/HashtableTest.java | 50 +++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) 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 b8a8035df24..8cbad333ad6 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -1052,10 +1052,22 @@ public static void clear(@Nonnull SizeManager sizeManager, @Nonnull Hashtable.En * 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) { - Hashtable.forEach(buckets, sink); - clear(buckets); + 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; + } + } } /** @@ -1063,12 +1075,21 @@ public static void drain( * {@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) { - Hashtable.forEach(buckets, context, sink); - clear(buckets); + 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; + } + } } /** @@ -1260,10 +1281,13 @@ public int evictAll( 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.size -= count; this.cursor = 0; return count; } 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 ce02a136cd6..91bead646fd 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -15,7 +15,9 @@ import datadog.trace.util.Hashtable.MutatingBucketIterator; import datadog.trace.util.Hashtable.MutatingTableIterator; import datadog.trace.util.Hashtable.Support; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.NoSuchElementException; import java.util.Set; import org.junit.jupiter.api.Nested; @@ -765,6 +767,54 @@ void evictOneReturnsNullWhenNothingMatches() { assertNotNull(buckets[0]); } + @Test + void evictAllKeepsCountConsistentWhenThePredicateThrows() { + Hashtable.State table = Hashtable.createCapped(8); + for (int i = 0; i < 4; i++) { + StringIntEntry e = new StringIntEntry("k" + i, i); + assertTrue(Hashtable.insertHeadEntryFor(table, e.keyHash, e)); + } + assertEquals(4, Hashtable.estimateSize(table)); + + // Removes some entries, then blows up. The count must reflect what actually left the table. + assertThrows( + IllegalStateException.class, + () -> + Hashtable.evictAll( + table, + e -> { + if (e.value == 3) { + throw new IllegalStateException("boom"); + } + return true; + })); + + int counted = Hashtable.estimateSize(table); + Set actuallyThere = new HashSet<>(); + Hashtable.forEach(table, e -> actuallyThere.add(e.key)); + assertEquals( + actuallyThere.size(), counted, "count must match the spine after a partial evictAll"); + } + + @Test + void drainDetachesEntriesSoASinkCannotPinTheChain() { + // Two entries forced into one bucket, so the drained pair is chained. + Hashtable.State table = Hashtable.createCapped(4); + CollidingKeyEntry first = new CollidingKeyEntry(new CollidingKey("first", 17), 1); + CollidingKeyEntry second = new CollidingKeyEntry(new CollidingKey("second", 17), 2); + assertTrue(Hashtable.insertHeadEntryFor(table, first.keyHash, first)); + assertTrue(Hashtable.insertHeadEntryFor(table, second.keyHash, second)); + + List drained = new ArrayList<>(); + Hashtable.drain(table, drained::add); + + assertEquals(2, drained.size()); + assertEquals(0, Hashtable.estimateSize(table), "drain resets the tracked count"); + for (CollidingKeyEntry e : drained) { + assertNull(e.next(), "a retained entry must not pin the rest of its chain"); + } + } + @Test void evictOneAdvancesCursorEvenWhenNothingMatches() { Hashtable.State table = Hashtable.createCapped(4); From 2d6bdb9438c4eaedbf04dadb53021ddf3bf59521 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 20:47:15 -0400 Subject: [PATCH 27/41] Add a selection guide to Hashtable and FlatHashtable Two questions, at the top of each class, written from that class's side: 1. Concurrent? -> ConcurrentHashtable, the only thread-safe one. 2. Otherwise, does the population reset wholesale or evolve? Cleared as a unit (per cycle, per request, built-then-discarded) -> the open-addressed FlatHashtable, which has no tombstones and so offers no removal beyond clearing. Entries coming and going independently -> the chained Hashtable, which removes and evicts in place. Lifetime is the usual shorthand for the second question, and the guide says where it mis-sorts: a long-lived table that resets on a cycle is a sequence of short lives and belongs with the short-lived ones. That case is real -- client-side stats has one table of each shape -- so the guide describes the two shapes rather than leaving a dev to discover the exception. These are the only cross-class references in Hashtable's docs; selection guidance is the one place a reader needs to know the siblings exist. Written as {@code} rather than {@link} so nothing dangles at a class outside this tree. Co-Authored-By: Claude Opus 5 (1M context) --- .../datadog/trace/util/FlatHashtable.java | 20 +++++++++++++++++++ .../java/datadog/trace/util/Hashtable.java | 20 +++++++++++++++++++ 2 files changed, 40 insertions(+) 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 5e078721ba3..f8f6731d9a7 100644 --- a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java @@ -43,6 +43,26 @@ * 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: * 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 8cbad333ad6..537f2a4c5b3 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -29,6 +29,26 @@ *

    For higher key dimensions, client code must implement its own class, but can still use the * 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) custom tables driven by the static * building blocks on this class (see {@link #create(Class, int)}, {@link From 2dab0299137a79d4af2d8ce6b8f1bf00ffd476f6 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 12:38:12 -0400 Subject: [PATCH 28/41] Add Hashtable.D1/D2 tryGetOrUpdate to keep the cap refusal off the caller's path tryGetOrCreate returns null once the table is at capacity, so the natural read-modify-write spelling table.tryGetOrCreate(key, Counter::new).inc(); compiles, tests, and then throws in production under cardinality pressure -- the one condition no unit test covers. Fusing the update keeps that reference inside the table: at capacity the update is skipped and false is returned. Delegates to tryGetOrCreate, so the hash is still computed once and there is no extra work versus doing it by hand. Context-passing overloads take the side-band value as an argument against a non-capturing BiConsumer, so a counter add does not allocate a capturing lambda per call. Co-Authored-By: Claude Opus 5 --- .../java/datadog/trace/util/Hashtable.java | 91 +++++++++++++++++++ .../datadog/trace/util/HashtableD1Test.java | 64 +++++++++++++ .../datadog/trace/util/HashtableD2Test.java | 63 +++++++++++++ 3 files changed, 218 insertions(+) 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 537f2a4c5b3..4cf6d8f44a3 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -335,6 +335,57 @@ public TEntry tryGetOrCreate( return newEntry; } + /** + * {@link #tryGetOrCreate} followed by {@code updater}, returning whether the update happened. + * + *

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

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

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

    No extra work versus doing it by hand -- the hash is still computed once, by the delegated + * {@link #tryGetOrCreate}. + */ + public boolean tryGetOrUpdate( + @Nullable K key, + @Nonnull Function creator, + @Nonnull Consumer updater) { + TEntry entry = tryGetOrCreate(key, creator); + if (entry == null) { + return false; + } + updater.accept(entry); + return true; + } + + /** + * Context-passing {@link #tryGetOrUpdate}, for updates that need a value the entry doesn't + * carry. {@code c -> c.add(n)} captures {@code n} and allocates a lambda per call; passing + * {@code n} as {@code context} against a non-capturing {@link BiConsumer} (typically a {@code + * static final}) does not. + */ + public boolean tryGetOrUpdate( + @Nullable K key, + @Nonnull Function creator, + C context, + @Nonnull BiConsumer updater) { + TEntry entry = tryGetOrCreate(key, creator); + if (entry == null) { + return false; + } + updater.accept(context, entry); + return true; + } + public void forEach(@Nonnull Consumer consumer) { Hashtable.forEach(this.buckets, consumer); } @@ -573,6 +624,46 @@ public TEntry tryGetOrCreate( return newEntry; } + /** + * Two-key analogue of {@link D1#tryGetOrUpdate(Object, Function, Consumer)}: applies {@code + * updater} to the entry for {@code (key1, key2)}, creating one if absent, and returns whether + * the update happened. Returns {@code false} without updating when the pair is absent and the + * table is at capacity. See the single-key form for why fusing the update is preferred over + * {@code tryGetOrCreate(...)} followed by a dereference. + */ + public boolean tryGetOrUpdate( + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull BiFunction creator, + @Nonnull Consumer updater) { + TEntry entry = tryGetOrCreate(key1, key2, creator); + if (entry == null) { + return false; + } + updater.accept(entry); + return true; + } + + /** + * Context-passing {@link #tryGetOrUpdate(Object, Object, BiFunction, Consumer)}, for updates + * that need a value the entry doesn't carry. Pass a non-capturing {@link BiConsumer} (typically + * a {@code static final}) plus its side-band state as {@code context} to avoid allocating a + * capturing lambda per call. + */ + public boolean tryGetOrUpdate( + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull BiFunction creator, + C context, + @Nonnull BiConsumer updater) { + TEntry entry = tryGetOrCreate(key1, key2, creator); + if (entry == null) { + return false; + } + updater.accept(context, entry); + return true; + } + public void forEach(@Nonnull Consumer consumer) { Hashtable.forEach(this.buckets, consumer); } 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 aba39aa9296..7dbfe2790da 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -341,4 +341,68 @@ void drainOnEmptyTableDoesNothing() { assertEquals(0, drained.size()); assertEquals(0, table.size()); } + + @Test + void tryGetOrUpdateCreatesThenAppliesUpdater() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), e -> e.value += 5)); + assertEquals(1, table.size()); + assertEquals(5, table.get("a").value); + } + + @Test + void tryGetOrUpdateUpdatesExistingEntryInPlace() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + table.insert(new StringIntEntry("a", 10)); + StringIntEntry existing = table.get("a"); + assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), e -> e.value += 5)); + assertEquals(1, table.size()); + assertEquals(15, existing.value); + assertSame(existing, table.get("a")); + } + + @Test + void tryGetOrUpdateReturnsFalseAtCapacityWithoutUpdating() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + boolean[] updaterRan = {false}; + assertFalse( + table.tryGetOrUpdate( + "c", + k -> new StringIntEntry(k, 0), + e -> { + updaterRan[0] = true; + })); + assertFalse(updaterRan[0], "updater must not run when the create is refused"); + assertEquals(2, table.size()); + assertNull(table.get("c")); + } + + @Test + void tryGetOrUpdateAtCapacityStillUpdatesAnExistingKey() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), e -> e.value += 7)); + assertEquals(8, table.get("a").value); + } + + @Test + void tryGetOrUpdateWithContextPassesContextToUpdater() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 4, (n, e) -> e.value += n)); + assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 6, (n, e) -> e.value += n)); + assertEquals(1, table.size()); + assertEquals(10, table.get("a").value); + } + + @Test + void tryGetOrUpdateWithContextReturnsFalseAtCapacity() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 1); + table.insert(new StringIntEntry("a", 1)); + assertFalse( + table.tryGetOrUpdate("b", k -> new StringIntEntry(k, 0), 4, (n, e) -> e.value += n)); + assertEquals(1, 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 f513299242e..16739c4a9a5 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java @@ -276,6 +276,69 @@ void drainOnEmptyTableDoesNothing() { assertEquals(0, table.size()); } + @Test + void tryGetOrUpdateCreatesThenAppliesUpdater() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + assertTrue( + table.tryGetOrUpdate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 0), e -> e.value += 5)); + assertEquals(1, table.size()); + assertEquals(5, table.get("a", 1).value); + } + + @Test + void tryGetOrUpdateUpdatesExistingEntryInPlace() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + table.insert(new PairEntry("a", 1, 10)); + PairEntry existing = table.get("a", 1); + assertTrue( + table.tryGetOrUpdate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 0), e -> e.value += 5)); + assertEquals(1, table.size()); + assertEquals(15, existing.value); + assertSame(existing, table.get("a", 1)); + } + + @Test + void tryGetOrUpdateReturnsFalseAtCapacityWithoutUpdating() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); + table.insert(new PairEntry("a", 1, 1)); + table.insert(new PairEntry("b", 2, 2)); + boolean[] updaterRan = {false}; + assertFalse( + table.tryGetOrUpdate( + "c", + 3, + (k1, k2) -> new PairEntry(k1, k2, 0), + e -> { + updaterRan[0] = true; + })); + assertFalse(updaterRan[0], "updater must not run when the create is refused"); + assertEquals(2, table.size()); + assertNull(table.get("c", 3)); + } + + @Test + void tryGetOrUpdateWithContextPassesContextToUpdater() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + assertTrue( + table.tryGetOrUpdate( + "a", 1, (k1, k2) -> new PairEntry(k1, k2, 0), 4, (n, e) -> e.value += n)); + assertTrue( + table.tryGetOrUpdate( + "a", 1, (k1, k2) -> new PairEntry(k1, k2, 0), 6, (n, e) -> e.value += n)); + assertEquals(1, table.size()); + assertEquals(10, table.get("a", 1).value); + } + + @Test + void tryGetOrUpdateWithContextReturnsFalseAtCapacity() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 1); + table.insert(new PairEntry("a", 1, 1)); + assertFalse( + table.tryGetOrUpdate( + "b", 2, (k1, k2) -> new PairEntry(k1, k2, 0), 4, (n, e) -> e.value += n)); + assertEquals(1, table.size()); + } + private static final class PairEntry extends Hashtable.D2.Entry { int value; From 60b3b11b2c64af58625aee2fc027aa4b5e42e7a9 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 12:53:19 -0400 Subject: [PATCH 29/41] Add a primitive-long context overload of Hashtable.D1.tryGetOrUpdate The generic context overload boxes on every call, which would make the counter-accumulate shape allocate where the hand-rolled tryGetOrCreate + null-check + field-write it replaces did not. ObjLongConsumer closes that gap for the one shape that motivated tryGetOrUpdate in the first place. D1 only -- D2 has no caller for it yet. Co-Authored-By: Claude Opus 5 --- .../java/datadog/trace/util/Hashtable.java | 26 ++++++++++++ .../datadog/trace/util/HashtableD1Test.java | 41 +++++++++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) 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 4cf6d8f44a3..8bcb923f72b 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -9,6 +9,7 @@ import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.ObjLongConsumer; import java.util.function.Predicate; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -386,6 +387,31 @@ public boolean tryGetOrUpdate( return true; } + /** + * Primitive-{@code long} {@link #tryGetOrUpdate}, for the accumulate-a-count shape: + * + *

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

    The generic context overload would box {@code n} on every call; this one does not. Note + * the argument order is {@code (entry, value)} -- {@link ObjLongConsumer}'s, not the {@code + * (context, entry)} of the {@link BiConsumer} overload. + */ + public boolean tryGetOrUpdate( + @Nullable K key, + @Nonnull Function creator, + long context, + @Nonnull ObjLongConsumer updater) { + TEntry entry = tryGetOrCreate(key, creator); + if (entry == null) { + return false; + } + updater.accept(entry, context); + return true; + } + public void forEach(@Nonnull Consumer consumer) { Hashtable.forEach(this.buckets, consumer); } 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 7dbfe2790da..2c1a28b4bf4 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -12,6 +12,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.function.ObjLongConsumer; import org.junit.jupiter.api.Test; class HashtableD1Test { @@ -391,8 +392,13 @@ void tryGetOrUpdateAtCapacityStillUpdatesAnExistingKey() { @Test void tryGetOrUpdateWithContextPassesContextToUpdater() { Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); - assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 4, (n, e) -> e.value += n)); - assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 6, (n, e) -> e.value += n)); + // Boxed on purpose: an int literal would bind to the primitive-long overload instead. + assertTrue( + table.tryGetOrUpdate( + "a", k -> new StringIntEntry(k, 0), Integer.valueOf(4), (n, e) -> e.value += n)); + assertTrue( + table.tryGetOrUpdate( + "a", k -> new StringIntEntry(k, 0), Integer.valueOf(6), (n, e) -> e.value += n)); assertEquals(1, table.size()); assertEquals(10, table.get("a").value); } @@ -402,7 +408,36 @@ void tryGetOrUpdateWithContextReturnsFalseAtCapacity() { Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 1); table.insert(new StringIntEntry("a", 1)); assertFalse( - table.tryGetOrUpdate("b", k -> new StringIntEntry(k, 0), 4, (n, e) -> e.value += n)); + table.tryGetOrUpdate( + "b", k -> new StringIntEntry(k, 0), Integer.valueOf(4), (n, e) -> e.value += n)); + assertEquals(1, table.size()); + } + + @Test + void tryGetOrUpdateWithLongContextCreatesThenAccumulates() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 4L, ADD_LONG)); + assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 6L, ADD_LONG)); + assertEquals(1, table.size()); + assertEquals(10, table.get("a").value); + } + + @Test + void tryGetOrUpdateWithLongContextReturnsFalseAtCapacityWithoutUpdating() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 1); + table.insert(new StringIntEntry("a", 1)); + assertFalse(table.tryGetOrUpdate("b", k -> new StringIntEntry(k, 0), 4L, ADD_LONG)); assertEquals(1, table.size()); + assertEquals(1, table.get("a").value); + } + + @Test + void tryGetOrUpdateWithLongContextAtCapacityStillUpdatesAnExistingKey() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 1); + table.insert(new StringIntEntry("a", 1)); + assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 4L, ADD_LONG)); + assertEquals(5, table.get("a").value); } + + private static final ObjLongConsumer ADD_LONG = (e, n) -> e.value += (int) n; } From d813ca014a59ebabb44126a2cb91af3c18bc6200 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 14:36:27 -0400 Subject: [PATCH 30/41] Record the capped-table rerun of HashtableD1Benchmark Adds the 5-fork Java 17 numbers for the State-backed table alongside the existing tables, and notes that JMH's Blackhole auto-detect picked a different mode than the previous Java 17 run did on the same JVM build -- so absolute numbers are only comparable within a table. add_hashtable now loses to HashMap by ~19% rather than being roughly comparable; update (~3.2x) and iterate (~1.35x) still win. Co-Authored-By: Claude Opus 5 --- .../trace/util/HashtableD1Benchmark.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 6efea71c1a9..f9bcb0de96e 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java @@ -96,6 +96,34 @@ * substitute for {@code HashMap} particularly for simple counter/tally use cases with a primitive * value, where avoiding the per-update boxing allocation pays off even on a JVM with much better * allocation handling than JDK 8 had. + * + *

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

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

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

    That is the right side of the trade for this family. {@code Hashtable} and {@link + * ConcurrentHashtable} are designed for workloads where updates dominate: the table is + * populated once and then hit repeatedly, so per-insert cost amortizes away and in-place mutation + * of a primitive field is the operation that runs hot. Paying on {@code add} to make {@code update} + * faster is the trade those workloads want. {@code FlatHashtable} and {@code TagMap} sit at the + * other end -- built up and read, not updated in a loop -- so this result does not transfer to + * them, and neither does the reasoning that justifies it. */ @Fork(2) @Warmup(iterations = 2) From b75671061e288475345ed9bd4585c94d16fc33e9 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 13:03:54 -0400 Subject: [PATCH 31/41] Assert against double-inserting the same Entry instance insertHeadEntryAt has no guard against splicing an Entry into a chain it is already linked in -- doing so silently produces a self-loop or a multi-node cycle, which every chain walk in this class (get, getOrCreate, forEach, and all three iterators) then spins on forever since none of them detect cycles. --- internal-api/src/main/java/datadog/trace/util/Hashtable.java | 2 ++ 1 file changed, 2 insertions(+) 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 8bcb923f72b..3692a365f76 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -861,6 +861,8 @@ public static TEntry bucketFor( */ 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; } From 846dcc363ba1d0b4da6ebc2ca37e8d60a720f45e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 21:40:35 -0400 Subject: [PATCH 32/41] Guard MutatingBucketIterator.replace against relinking an already-linked Entry bric3 hit the case while trying the Hashtable API: splicing in an Entry that's still part of another chain silently corrupts it. insertHeadEntryAt already asserted against this; add the same assertion to replace(), note the caller contract on D1.insert's javadoc, and add a regression test. Co-Authored-By: Claude Sonnet 5 --- .../java/datadog/trace/util/Hashtable.java | 7 +++++- .../datadog/trace/util/HashtableTest.java | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) 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 3692a365f76..0e0b4352130 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -253,7 +253,10 @@ public TEntry remove(@Nullable K key) { /** * 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. + * 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); @@ -1802,6 +1805,8 @@ public void replace(@Nonnull TEntry replacementEntry) { 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); 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 91bead646fd..8b278f5428c 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -10,6 +10,7 @@ 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; @@ -200,6 +201,21 @@ void insertHeadEntrySplicesAsNewHead() { assertSame(a, b.next()); assertNull(a.next()); } + + @Test + void insertHeadEntryOfAlreadyLinkedEntryTripsAssertion() { + assumeTrue(assertionsEnabled(), "assert-guard test requires -ea"); + Hashtable.State table = Hashtable.createCapped(4); + Hashtable.Entry[] buckets = table.buckets; + StringIntEntry a = new StringIntEntry("a", 1); + StringIntEntry b = new StringIntEntry("b", 2); + Hashtable.insertHeadEntryAt(buckets, 0, a); + 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 ============ @@ -923,4 +939,10 @@ void tableSizeManagerOperatesOnItsOwnBuckets() { assertNull(table.buckets[0]); } } + + private static boolean assertionsEnabled() { + boolean enabled = false; + assert enabled = true; + return enabled; + } } From 553d81c00346bbb5132b4e05ff93704edb5ca80a Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 14:01:20 -0400 Subject: [PATCH 33/41] Carry Maybe forward pending #12328 merge Maybe/MaybeTest/EscapeShapeBenchmark/MaybeUsagePatternsBenchmark, copied verbatim from the merge-queued PR #12328, so this branch (stacked on #12101) can add Maybe-returning Hashtable/FlatHashtable methods without waiting on the queue. Drop this commit's contents in favor of master's copy once this branch rebases past #12328 landing. --- .../util/MaybeUsagePatternsBenchmark.java | 191 +++++++++ .../util/escape/EscapeShapeBenchmark.java | 404 ++++++++++++++++++ .../main/java/datadog/trace/util/Maybe.java | 156 +++++++ .../java/datadog/trace/util/MaybeTest.java | 114 +++++ 4 files changed, 865 insertions(+) create mode 100644 internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java create mode 100644 internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java create mode 100644 internal-api/src/main/java/datadog/trace/util/Maybe.java create mode 100644 internal-api/src/test/java/datadog/trace/util/MaybeTest.java diff --git a/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java new file mode 100644 index 00000000000..07a05f69627 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java @@ -0,0 +1,191 @@ +package datadog.trace.util; + +import java.util.function.BiConsumer; +import java.util.function.ObjLongConsumer; +import javax.annotation.Nullable; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * A do/don't guide for using {@link Maybe}, not a research instrument like {@code + * datadog.trace.util.escape.EscapeShapeBenchmark} (which this class's arms are built on top of). + * Read {@code gc.alloc.rate.norm} — the "good" arm in each pair is expected to read 0 B/op on every + * JDK the way {@code EscapeShapeBenchmark}'s {@code singleSite}/{@code passedToInlinedStrategy} + * arms do; the paired "bad" arm exists to make the regression visible rather than theoretical. Run + * as + * + *

    + * ./gradlew :internal-api:jmh -Pjmh.includes=MaybeUsagePatternsBenchmark -Pjmh.profilers=gc -PtestJvm=17
    + * 
    + * + * This is the intended backing example for a perf-review check like "EA-dependent elision on a hot + * path where a structural alternative exists at parity → prefer the deterministic form": both pairs + * below have a same-cost deterministic form available, so reviewing a real diff against these arms + * is a matter of asking "which arm does this call site look like," not re-deriving the + * escape-analysis argument each time. + * + *

    The boxed-context pair is the sharper illustration of that phrase than it first looks + * like. {@code badBoxedContextUpdateInlined} was expected to allocate the boxed {@code Long} + * and, measured here, does not -- with the whole {@code update} call inlined, C2 scalar-replaces + * the box the same as it would any other short-lived object. That is exactly the "EA-dependent" + * half of that phrase: {@link Maybe#update(long, ObjLongConsumer)} has no box to eliminate + * in the first place, so it reads 0 B/op regardless of whether the mutator lambda's own inlining + * holds; the generic-context form's 0 B/op is contingent on that specific inlining, which {@code + * badBoxedContextUpdateUninlined} demonstrates by taking it away via the same {@code + * -XX:CompileCommand=dontinline} technique {@code EscapeShapeBenchmark} uses for its {@code + * UninlinedStrategy} arm. This is narrower than immunity to every inlining failure: if the + * producing method or the {@code update} call itself fails to inline -- a different boundary, + * exercised by {@code EscapeShapeBenchmark}'s {@code passedToUninlinedStrategy} arm (24 B/op) -- + * the {@code Maybe} wrapper itself becomes a real allocation for either overload. + */ +@Fork( + value = 2, + jvmArgsAppend = { + "-XX:CompileCommand=dontinline,datadog.trace.util.MaybeUsagePatternsBenchmark$UninlinedBoxedAdder::accept" + }) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@Threads(1) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(java.util.concurrent.TimeUnit.NANOSECONDS) +@State(Scope.Thread) +public class MaybeUsagePatternsBenchmark { + + static final class Widget { + long count; + } + + /** A non-capturing updater, as {@link Maybe#update(long, ObjLongConsumer)} expects. */ + static final ObjLongConsumer ADD_PRIMITIVE = (w, delta) -> w.count += delta; + + /** + * The same update expressed through the generic-context overload instead. {@code Long} is not + * assignable from {@code long} without boxing, so calling {@link Maybe#update(Object, + * BiConsumer)} with a {@code long} argument boxes it every time — the exact per-call allocation + * {@link Maybe#update(long, ObjLongConsumer)} exists to avoid. Kept as a {@code + * BiConsumer} rather than inlined at the call site so the two arms below differ + * only in which overload is selected, not in lambda shape. + */ + static final BiConsumer ADD_BOXED_INLINED = (w, delta) -> w.count += delta; + + /** + * Same logic as {@link #ADD_BOXED_INLINED}, but as a named class rather than a lambda so {@code + * -XX:CompileCommand=dontinline} (see this class's {@link Fork} annotation) has a concrete method + * to target -- kept out of line the same way {@code EscapeShapeBenchmark}'s {@code + * UninlinedStrategy} is, by the {@code CompileCommand} rather than {@code CompilerControl}, since + * JMH's processor only reads that annotation from {@code @Benchmark} methods. + */ + static final class UninlinedBoxedAdder implements BiConsumer { + @Override + public void accept(Widget w, Long delta) { + w.count += delta; + } + } + + static final BiConsumer ADD_BOXED_UNINLINED = new UninlinedBoxedAdder(); + + /** + * Deliberately outside {@code Long}'s [-128, 127] cache range -- a cached delta like {@code 1L} + * would make {@link #badBoxedContextUpdateUninlined} read 0 B/op too, for a reason with nothing + * to do with which overload got picked. + */ + static final long DELTA = 1_000L; + + private final Widget[] table = new Widget[8]; + private int counter; + + public MaybeUsagePatternsBenchmark() { + for (int i = 0; i < table.length; i++) { + // Half the slots stay null so every arm below actually exercises the refused/empty path, + // not just the present one -- see EscapeShapeBenchmark's `alternate()` javadoc for why an + // always-taken branch would quietly turn these into single-site arms and lie. + if ((i & 1) == 0) { + table[i] = new Widget(); + } + } + } + + private int nextKey() { + return (counter++) & (table.length - 1); + } + + @Nullable + private Widget lookup(int key) { + return table[key]; + } + + /** + * GOOD: exactly one {@code Maybe.of(...)} call site, fed by delegating to the existing nullable + * method. See {@link Maybe}'s class javadoc for why this is the recommended shape. + */ + private Maybe tryLookupDelegating(int key) { + return Maybe.of(lookup(key)); + } + + /** + * BAD: a {@code Maybe.of(...)} call site per branch. Both branches return the same wrapper type, + * so this looks equivalent to {@link #tryLookupDelegating} at every call site that uses it — the + * difference only shows up here, in the allocation profile of the method that builds the {@code + * Maybe}, which is exactly why it is easy to introduce by accident. + */ + private Maybe tryLookupMultiSite(int key) { + Widget w = lookup(key); + if (w != null) { + return Maybe.of(w); + } else { + return Maybe.of(null); + } + } + + @Benchmark + public void goodSingleConstructionSite(Blackhole bh) { + Maybe t = tryLookupDelegating(nextKey()); + bh.consume(t.isPresent()); + } + + @Benchmark + public void badMultiConstructionSite(Blackhole bh) { + Maybe t = tryLookupMultiSite(nextKey()); + bh.consume(t.isPresent()); + } + + @Benchmark + public void goodPrimitiveContextUpdate(Blackhole bh) { + Maybe t = tryLookupDelegating(nextKey()); + t.update(DELTA, ADD_PRIMITIVE); + bh.consume(t.isPresent()); + } + + /** + * Reads 0 B/op here despite boxing {@link #DELTA} on every call -- this call site stays inlined, + * so C2 scalar-replaces the {@code Long} the same as any other non-escaping object. See {@link + * #badBoxedContextUpdateUninlined} for what that 0 is actually contingent on. + */ + @Benchmark + public void badBoxedContextUpdateInlined(Blackhole bh) { + Maybe t = tryLookupDelegating(nextKey()); + t.update(DELTA, ADD_BOXED_INLINED); + bh.consume(t.isPresent()); + } + + /** + * The same boxing, with only the inlining taken away (via {@link UninlinedBoxedAdder} and this + * class's {@code CompileCommand}). Whatever this costs above {@link #goodPrimitiveContextUpdate} + * is the box {@link #badBoxedContextUpdateInlined} was quietly relying on EA to remove. + */ + @Benchmark + public void badBoxedContextUpdateUninlined(Blackhole bh) { + Maybe t = tryLookupDelegating(nextKey()); + t.update(DELTA, ADD_BOXED_UNINLINED); + bh.consume(t.isPresent()); + } +} diff --git a/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java new file mode 100644 index 00000000000..8f9ab1c63fe --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java @@ -0,0 +1,404 @@ +package datadog.trace.util.escape; + +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Minimal code shapes, each isolating one thing that is believed to decide whether C2 can delete a + * short-lived object. Read {@code gc.alloc.rate.norm} — bytes per operation — not the timings: a + * shape that scalar-replaces reports 0, and one that does not reports the object's real size. Run + * it as + * + *

    ./gradlew :internal-api:jmh -Pjmh.includes=EscapeShape -Pjmh.profilers=gc -PtestJvm=17
    + * 
    + * + * and read the same rows across {@code -PtestJvm} 8, 11, 17, 21 and 25. The point is the matrix of + * shape against JDK, so that "will this allocate" stops being a question two people answer from + * memory. Nothing here is specific to any one caller: the arms model a generic two-outcome wrapper + * (something that is either present with a value or absent) and carry over unchanged to {@code + * Maybe}, because the shapes under test are about the compiler's allocation behavior, not about + * what the wrapped value represents. + * + *

    The underlying idea, before the terminology: the compiler can sometimes prove a short-lived + * object never needs to outlive the method that created it, and when it can, it skips putting that + * object on the heap at all -- it keeps the object's fields as plain local values instead. Three + * terms for that recur below. Escape analysis (EA) is the compiler's proof step -- showing + * an allocated object's lifetime is confined to the method (or thread) that created it, i.e. it + * never escapes into a field, a return value visible outside, or a call the compiler cannot see + * into. Scalar replacement is what C2 (HotSpot's JIT) does once that proof holds: the object + * itself disappears, and its individual fields live in registers or on the stack instead, so no + * heap allocation happens -- the arms below that read 0 B/op are exactly the ones EA proved safe. + * {@code ReduceAllocationMerges} (JDK-8287061) extends that same proof to one harder case: + * an if/else (or similar branch) where each side allocates its own object -- say {@code x = new + * Foo()} in one branch and {@code x = new Bar()} in the other -- and the code after the branch + * reads {@code x} without knowing which allocation actually ran. Before JDK 21, C2 could not + * scalar-replace either allocation once they were merged like this, even if each individually would + * have qualified on its own; {@code ReduceAllocationMerges} is what lets it do so starting at JDK + * 21, which is why a few rows below only drop to 0 starting at JDK 21/25 rather than on every JDK. + * All of this is specific to HotSpot's C2 JIT; none of it has been checked against OpenJ9 or + * GraalVM, which use different compilers with different heuristics and may not scalar-replace the + * same shapes. + * + *

    Every arm consumes the object's fields rather than the object. Handing the reference + * to a {@link Blackhole} would make it escape by construction and every row would read the same. + * + *

    Bytes per operation, one machine, {@code -Pjmh.forks=1}. A 16-byte object allocated on half + * the operations reads as 8. Columns are the JDK the fork ran on, which is not necessarily + * the JDK on the shell's path — take it from JMH's own {@code # VM version} line. + * + *

    + * shape                                 JDK 8  JDK 11  JDK 17  JDK 21  JDK 25   what it isolates
    + * singleSite                                0       ?       0       ?       0   the floor
    + * flagOnOneAllocation                       0       ?       0       ?       0   outcome in a field
    + * closedInFinally                           0       ?       0       ?       0   try/finally
    + * closedInFinallyWithThrow                  0       ?       0       ?       ?   ... with the handler taken
    + * flagOnOneAllocationClosedInFinally        0       ?       0       ?       0   flag field, whole
    + * passedToInlinedStrategy                   0       ?       0       ?       0   @Strategy boundary
    + * backingMonomorphic                        0       ?       0       ?       0   one backing
    + * backingBimorphic                          0       ?       0       ?       0   two backings
    + * mergeWithNull                             8       ?       8       ?       0   merge with null
    + * mergeWithStatic                           8       ?       8       ?       8   merge with a singleton
    + * mergeWithStaticClosedInFinally            8       ?       8       ?       8   ... the same, whole
    + * mergeOfTwoAllocations                    16       ?      16       ?      16   merge of two allocations
    + * passedToUninlinedStrategy                24       ?      24       ?      24   the same boundary, uninlined
    + * backingMegamorphic                       24       ?      24       ?      24   three backings
    + * 
    + * + *

    JDK 8 column measured 2026-08-27 (Zulu 8.72.0.17, this machine, {@code -Pjmh.fork=1}): every + * arm lands on the same B/op as the 17/25 columns it was checked against, including {@code + * mergeWithNull} staying at 8 rather than following JDK 25's drop to 0 — the {@code + * ReduceAllocationMerges} relaxation is JDK 21+ only, so 8's floor for this shape is the older, + * unconditional one. + * + *

    What the two measured columns say so far: + * + *

      + *
    • try/finally is free, including with the handler taken often enough to be compiled rather + * than left as an uncommon trap. It was the suspected culprit and it is not one. Note the + * catch is in the same method, so C2 can reduce the throw to control flow; this does not + * exercise an unwind through frames. + *
    • A merge with a static allocates on every JDK measured, JDK 25 included. The JDK 21 + * allocation-merge work shows up only in the {@code mergeWithNull} row, which goes 8 to 0; a + * merge of two live allocations still allocates at 25, because the merged reference is called + * through rather than only read from. + *
    • Moving the outcome into a field of a single allocation costs nothing, with or without the + * {@code finally}. That is the whole fix. + *
    • Inlining is the gate, and the strategy discipline is what holds it open: the same object + * through the same call boundary is 0 when the callee inlines and 24 when it does not. + *
    • Two backings behind a template method are free; three are not. The inheritance layout is + * not costing anything today, and would cost 24 bytes an operation the day a third arrives. + *
    + */ +@Fork( + value = 2, + jvmArgsAppend = { + "-XX:CompileCommand=dontinline,datadog.trace.util.escape.EscapeShapeBenchmark$UninlinedStrategy::apply" + }) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@Threads(1) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@State(Scope.Thread) +public class EscapeShapeBenchmark { + + /** + * Minimal two-method interface -- a value to read and a close to call -- standing in for any + * short-lived object more complex than a single field. + */ + interface Outcome { + int value(); + + void close(); + } + + static final class SingleAllocation implements Outcome { + private final int seed; + + SingleAllocation(int seed) { + this.seed = seed; + } + + @Override + public int value() { + return seed + 1; + } + + @Override + public void close() {} + } + + /** A second allocation site, for the merge that C2 has some chance with. */ + static final class AlternateAllocation implements Outcome { + private final int seed; + + AlternateAllocation(int seed) { + this.seed = seed; + } + + @Override + public int value() { + return seed + 2; + } + + @Override + public void close() {} + } + + /** The absent outcome, reachable from a static, so the merge it takes part in is not local. */ + static final Outcome STATIC_SINGLETON = + new Outcome() { + @Override + public int value() { + return 0; + } + + @Override + public void close() {} + }; + + /** One allocation site carrying the outcome in a field: the shape that survives. */ + static final class FlaggedAllocation { + private final boolean present; + private final int seed; + + FlaggedAllocation(boolean present, int seed) { + this.present = present; + this.seed = seed; + } + + int value() { + return present ? seed + 1 : 0; + } + + void close() {} + } + + /** + * A non-capturing strategy held in a static final field of concrete type, as {@code @Strategy} + * requires. + */ + interface OutcomeStrategy { + int apply(FlaggedAllocation cell); + } + + static final OutcomeStrategy INLINED = FlaggedAllocation::value; + + /** + * Kept out of line by the {@code CompileCommand} in {@link Fork}, not by {@link CompilerControl}: + * JMH's processor only collects that annotation from {@code @Benchmark} methods, so putting it + * here emits no hint at all and the arm silently becomes a duplicate of the inlined one. Check + * the timing against {@code passedToInlinedStrategy} before believing this row — a call that + * really did not inline cannot cost the same as no call. + */ + static final class UninlinedStrategy implements OutcomeStrategy { + @Override + public int apply(FlaggedAllocation cell) { + return cell.value(); + } + } + + static final OutcomeStrategy UNINLINED = new UninlinedStrategy(); + + /** + * The template-method shape: a final method on a base type calling out to an abstract one, with + * the object under test riding along as the argument. How many concrete subclasses are loaded is + * the whole experiment — C2 inlines a monomorphic call outright and a bimorphic one behind a type + * guard, but gives up at three, and a call it does not inline turns its argument into an escape. + */ + abstract static class Backing { + final int admit(FlaggedAllocation cell) { + return store(cell); + } + + abstract int store(FlaggedAllocation cell); + } + + static final class ArrayBacking extends Backing { + @Override + int store(FlaggedAllocation cell) { + return cell.value(); + } + } + + static final class LinkedBacking extends Backing { + @Override + int store(FlaggedAllocation cell) { + return cell.value() + 1; + } + } + + static final class ThirdBacking extends Backing { + @Override + int store(FlaggedAllocation cell) { + return cell.value() + 2; + } + } + + // All three the same length, so the index arithmetic and the bounds check are identical and the + // only difference between the arms is how many types reach the call site. + // + // Unexplained: the monomorphic arm times slower than the bimorphic one (2.14 against 1.26 ns on + // 17), and equalising the lengths did not change it, so it is not the index arithmetic. Both + // eliminate their allocation, which is what this matrix is for, so the timing oddity does not + // touch any conclusion drawn here — but do not quote these two timings against each other until + // someone has read the assembly. + private final Backing[] one = {new ArrayBacking(), new ArrayBacking(), new ArrayBacking()}; + private final Backing[] two = {new ArrayBacking(), new LinkedBacking(), new ArrayBacking()}; + private final Backing[] three = {new ArrayBacking(), new LinkedBacking(), new ThirdBacking()}; + + // The three arms below are deliberately copy-pasted rather than sharing a helper. A shared helper + // would carry one profile for all three call sites, so the megamorphic arm would poison the other + // two and the matrix would report the same answer three times. + + @Benchmark + public void backingMonomorphic(Blackhole bh) { + Backing backing = one[(counter++ & 0x7fffffff) % one.length]; + FlaggedAllocation cell = new FlaggedAllocation(true, counter); + bh.consume(backing.admit(cell)); + } + + @Benchmark + public void backingBimorphic(Blackhole bh) { + Backing backing = two[(counter++ & 0x7fffffff) % two.length]; + FlaggedAllocation cell = new FlaggedAllocation(true, counter); + bh.consume(backing.admit(cell)); + } + + @Benchmark + public void backingMegamorphic(Blackhole bh) { + Backing backing = three[(counter++ & 0x7fffffff) % three.length]; + FlaggedAllocation cell = new FlaggedAllocation(true, counter); + bh.consume(backing.admit(cell)); + } + + /** + * Alternates so both sides of every branch are taken and the profile is honest. A branch C2 never + * sees taken becomes an uncommon trap, which would quietly turn the merge arms into single-site + * arms and make the whole matrix a lie. + */ + private int counter; + + private boolean alternate() { + return (counter++ & 1) == 0; + } + + @Benchmark + public void singleSite(Blackhole bh) { + SingleAllocation cell = new SingleAllocation(counter++); + bh.consume(cell.value()); + } + + @Benchmark + public void mergeOfTwoAllocations(Blackhole bh) { + Outcome cell = alternate() ? new SingleAllocation(counter) : new AlternateAllocation(counter); + bh.consume(cell.value()); + } + + @Benchmark + public void mergeWithStatic(Blackhole bh) { + Outcome cell = alternate() ? new SingleAllocation(counter) : STATIC_SINGLETON; + bh.consume(cell.value()); + } + + @Benchmark + public void mergeWithNull(Blackhole bh) { + SingleAllocation cell = alternate() ? new SingleAllocation(counter) : null; + bh.consume(cell == null ? 0 : cell.value()); + } + + @Benchmark + public void flagOnOneAllocation(Blackhole bh) { + FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter); + bh.consume(cell.value()); + } + + @Benchmark + public void closedInFinally(Blackhole bh) { + SingleAllocation cell = new SingleAllocation(counter++); + try { + bh.consume(cell.value()); + } finally { + cell.close(); + } + } + + /** Preallocated and stackless, so the arm measures control flow rather than fillInStackTrace. */ + static final class Failure extends RuntimeException { + static final Failure INSTANCE = new Failure(); + + private Failure() { + super("failure", null, false, false); + } + } + + /** + * The same try/finally, with the handler actually taken often enough to be compiled rather than + * left as an uncommon trap. This is the case {@link #closedInFinally} does not cover: there, C2 + * has never seen the exception path, so there is no code for the object to be live into. + */ + @Benchmark + public void closedInFinallyWithThrow(Blackhole bh) { + SingleAllocation cell = new SingleAllocation(counter++); + try { + if ((counter & 15) == 0) { + throw Failure.INSTANCE; + } + bh.consume(cell.value()); + } catch (Failure failure) { + bh.consume(cell.value() + 1); + } finally { + cell.close(); + } + } + + /** The Optional-style shape, whole: a singleton for one outcome, under try/finally. */ + @Benchmark + public void mergeWithStaticClosedInFinally(Blackhole bh) { + Outcome cell = alternate() ? new SingleAllocation(counter) : STATIC_SINGLETON; + try { + bh.consume(cell.value()); + } finally { + cell.close(); + } + } + + /** The single-site shape, whole: one allocation carrying a flag, under try/finally. */ + @Benchmark + public void flagOnOneAllocationClosedInFinally(Blackhole bh) { + FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter); + try { + bh.consume(cell.value()); + } finally { + cell.close(); + } + } + + /** + * A non-escaping object handed across a call boundary the strategy discipline keeps inlinable. + */ + @Benchmark + public void passedToInlinedStrategy(Blackhole bh) { + FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter); + bh.consume(INLINED.apply(cell)); + } + + /** + * The same, with only the inlining taken away. Whatever this costs is what the discipline buys. + */ + @Benchmark + public void passedToUninlinedStrategy(Blackhole bh) { + FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter); + bh.consume(UNINLINED.apply(cell)); + } +} diff --git a/internal-api/src/main/java/datadog/trace/util/Maybe.java b/internal-api/src/main/java/datadog/trace/util/Maybe.java new file mode 100644 index 00000000000..f69bc9784ca --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/util/Maybe.java @@ -0,0 +1,156 @@ +package datadog.trace.util; + +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.ObjDoubleConsumer; +import java.util.function.ObjIntConsumer; +import java.util.function.ObjLongConsumer; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Candidate return shape for fallible operations (e.g. a table's capacity-refusing {@code + * tryGetOrCreate}), evaluating whether it can be allocation-free under escape analysis. + * + *

    Deliberately shaped against the {@code Optional}-style merge-with-singleton pitfall: {@link + * #of} is the only allocation site, always allocates (never returns a shared instance), and holds a + * plain nullable field. See {@code EscapeShapeBenchmark}'s {@code phiWithStatic} arm for why an + * {@code EMPTY} singleton would cost 8 B/op on every JDK measured, including 25. + * + *

    This shape -- one allocation site, a plain nullable field, no singleton merge -- scalar- + * replaces on ordinary escape analysis, on JDK 8/11/17/25, no JDK-21+ {@code + * ReduceAllocationMerges} needed (see {@code EscapeShapeBenchmark}). The discipline required of a + * caller is that the wrapping method itself construct a {@code Maybe} at exactly one call site (fed + * by a plain nullable local merged through ordinary branches, or by delegating to an + * already-nullable-returning method) rather than once per {@code return} statement -- multiple + * construction sites inline into a multi-producer phi that fails scalar replacement on JDK + * 8/11/17/21 (measured 16 B/op, {@code MaybeUsagePatternsBenchmark#badMultiConstructionSite}) once + * the refusal branch is reachable. On JDK 25, {@code ReduceAllocationMerges} collapses this + * specific shape -- two branches allocating the same final type with identical field layout -- back + * down to 0 B/op; do not rely on that JDK-25-only behavior, since it is exactly the kind of + * EA-dependent elision that can regress silently the moment the two branches stop being trivially + * mergeable (e.g. one branch gains extra state). See {@code EscapeShapeBenchmark}'s {@code + * phiOfTwoAllocations} arm, which uses two distinct interface implementations rather than one + * concrete type and therefore fails to scalar-replace on every JDK including 25 -- a different, + * stronger failure mode than the one demonstrated here. + */ +public final class Maybe { + @Nullable private final T value; + + private Maybe(@Nullable T value) { + this.value = value; + } + + @Nonnull + public static Maybe of(@Nullable T value) { + return new Maybe<>(value); + } + + /** + * Convenience form for the common shape {@code Maybe.of(receiver.someNullableMethod(args))}: + * {@code Maybe.of(receiver, r -> r.someNullableMethod(args))}. Useful when {@code receiver} would + * otherwise have to be re-evaluated or named twice at the call site. + * + *

    Unlike the single-arg {@link #of}, {@code fn} here is typically a capturing lambda + * -- it closes over whatever local arguments the caller's method has in scope, so a fresh lambda + * instance is created on every invocation (capturing lambdas are never cached the way a + * non-capturing lambda's singleton instance commonly is) -- which makes it a second heap-object + * candidate distinct from the {@code Maybe} itself. That freshly-allocated capturing lambda still + * scalar-replaces as reliably as a plain delegating method call does, for the shape actually + * measured (JDK 8/11/17/25): a monomorphic receiver and a {@code fn} that is applied exactly once + * and does not itself escape (e.g. by being stored or passed further). If {@code fn} itself + * captures something that must be freshly allocated per call (e.g. a non-singleton creator), that + * allocation is real regardless of what happens to the lambda wrapping it. + */ + @Nonnull + public static Maybe of(R receiver, @Nonnull Function fn) { + return new Maybe<>(fn.apply(receiver)); + } + + public boolean isPresent() { + return value != null; + } + + /** + * Raw accessor -- named to make the null case unmissable at the call site, rather than {@code + * orElse}/{@code get}, neither of which says so on its own. + */ + @Nullable + public T getOrNull() { + return value; + } + + /** + * 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. + */ + public void update(Consumer mutator) { + if (value != null) { + mutator.accept(value); + } + } + + /** + * Generic-context form of {@link #update(Consumer)}, for callers that already have a reusable, + * non-capturing {@code BiConsumer} (typically a {@code static final}) plus whatever context it + * needs -- {@code (value, context)} to stay consistent with the primitive-context overloads + * below, at the cost of departing from {@code Hashtable#forEach}'s {@code (context, entry)} + * convention. + */ + public void update(C context, BiConsumer mutator) { + if (value != null) { + mutator.accept(value, context); + } + } + + /** + * Primitive-context form of {@link #update(Consumer)}, for the common case where the mutation + * 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. + * + *

    Deliberately the only primitive-context overload of {@code update}. An {@code + * int}/{@code boolean} sibling was tried and reverted: Java's overload resolution can pick + * cleanly between a primitive overload and the generic {@link #update(Object, BiConsumer)} form + * for a reference-typed argument (boxing is only considered once no non-boxing candidate + * applies), but that guarantee does not extend to a second primitive overload -- {@code update(1, + * lambda)} is ambiguous between {@code int} and {@code long} even with no {@code double} overload + * in the picture, because {@link ObjIntConsumer} and {@link ObjLongConsumer} are unrelated + * interfaces and JLS 15.12.2.5's most-specific-method rule requires every parameter position to + * agree, not just the numeric one. Confirmed by direct compilation, not just JLS reading: an + * inline lambda call breaks as soon as a second primitive overload exists. A plain {@code int} + * argument still widens to {@code long} for free at this single overload -- callers are not + * required to have a {@code long} in hand. {@code double} context is rare enough not to bother + * keeping pretty -- see {@link #updateDouble} for that case, given its own name to sidestep the + * ambiguity rather than trying to squeeze it into an overload. + */ + public void update(long context, ObjLongConsumer mutator) { + if (value != null) { + mutator.accept(value, context); + } + } + + /** + * {@code double}-context sibling of {@link #update(long, ObjLongConsumer)}, given a distinct name + * rather than a second primitive overload -- see that method's javadoc for why overloading {@code + * update} a second time breaks inline-lambda call sites. + */ + public void updateDouble(double context, ObjDoubleConsumer mutator) { + if (value != null) { + mutator.accept(value, context); + } + } + + public void ifPresentOrElse(Consumer action, Runnable emptyAction) { + if (value != null) { + action.accept(value); + } else { + emptyAction.run(); + } + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/MaybeTest.java b/internal-api/src/test/java/datadog/trace/util/MaybeTest.java new file mode 100644 index 00000000000..2420d530bbe --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/MaybeTest.java @@ -0,0 +1,114 @@ +package datadog.trace.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +public class MaybeTest { + + static final class Widget { + long count; + double total; + } + + @Test + public void ofPresent() { + Maybe maybe = Maybe.of("value"); + assertTrue(maybe.isPresent()); + assertEquals("value", maybe.getOrNull()); + } + + @Test + public void ofAbsent() { + Maybe maybe = Maybe.of(null); + assertFalse(maybe.isPresent()); + assertNull(maybe.getOrNull()); + } + + @Test + public void ofReceiverFunctionPresent() { + Maybe maybe = Maybe.of("value", String::length); + assertTrue(maybe.isPresent()); + assertEquals(5, maybe.getOrNull()); + } + + @Test + public void ofReceiverFunctionAbsent() { + Maybe maybe = Maybe.of("value", r -> null); + assertFalse(maybe.isPresent()); + assertNull(maybe.getOrNull()); + } + + @Test + public void updateConsumerRunsWhenPresent() { + Widget w = new Widget(); + Maybe.of(w).update(widget -> widget.count = 42); + assertEquals(42, w.count); + } + + @Test + public void updateConsumerNoOpWhenAbsent() { + Maybe maybe = Maybe.of(null); + maybe.update(widget -> widget.count = 42); + assertFalse(maybe.isPresent()); + } + + @Test + public void updateBiConsumerRunsWhenPresent() { + Widget w = new Widget(); + Maybe.of(w).update("context", (widget, ctx) -> widget.count = ctx.length()); + assertEquals(7, w.count); + } + + @Test + public void updateBiConsumerNoOpWhenAbsent() { + Maybe maybe = Maybe.of(null); + maybe.update("context", (widget, ctx) -> widget.count = ctx.length()); + assertFalse(maybe.isPresent()); + } + + @Test + public void updateLongRunsWhenPresent() { + Widget w = new Widget(); + Maybe.of(w).update(5L, (widget, delta) -> widget.count += delta); + assertEquals(5, w.count); + } + + @Test + public void updateLongNoOpWhenAbsent() { + Maybe maybe = Maybe.of(null); + maybe.update(5L, (widget, delta) -> widget.count += delta); + assertFalse(maybe.isPresent()); + } + + @Test + public void updateDoubleRunsWhenPresent() { + Widget w = new Widget(); + Maybe.of(w).updateDouble(2.5, (widget, delta) -> widget.total += delta); + assertEquals(2.5, w.total); + } + + @Test + public void updateDoubleNoOpWhenAbsent() { + Maybe maybe = Maybe.of(null); + maybe.updateDouble(2.5, (widget, delta) -> widget.total += delta); + assertFalse(maybe.isPresent()); + } + + @Test + public void ifPresentOrElseRunsActionWhenPresent() { + StringBuilder sb = new StringBuilder(); + Maybe.of("value").ifPresentOrElse(sb::append, () -> sb.append("empty")); + assertEquals("value", sb.toString()); + } + + @Test + public void ifPresentOrElseRunsEmptyActionWhenAbsent() { + StringBuilder sb = new StringBuilder(); + Maybe.of(null).ifPresentOrElse(sb::append, () -> sb.append("empty")); + assertEquals("empty", sb.toString()); + } +} From a129e7bef1de4980c66c4617ec725d50fe53d381 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 14:07:52 -0400 Subject: [PATCH 34/41] Add Maybe-returning tryGetOrCreateAsMaybe to Hashtable and FlatHashtable Additive siblings to tryGetOrCreate on Hashtable.D1/D2 and FlatHashtable.D1/D2, wrapping the existing @Nullable-returning method in a Maybe rather than changing its signature. Each delegates to the existing tryGetOrCreate as its sole Maybe#of call site, keeping the allocation-free shape Maybe's class javadoc requires. Validates Maybe against a real caller: the client-side-stats PR (#12312) stacked on top of this one converts CardinalityLimitReporter to tryGetOrCreateAsMaybe(...).update(...). --- .../datadog/trace/util/FlatHashtable.java | 23 ++++++++++++ .../java/datadog/trace/util/Hashtable.java | 29 +++++++++++++++ .../trace/util/FlatHashtableD1Test.java | 21 +++++++++++ .../trace/util/FlatHashtableD2Test.java | 25 +++++++++++++ .../datadog/trace/util/HashtableD1Test.java | 36 +++++++++++++++++++ .../datadog/trace/util/HashtableD2Test.java | 15 ++++++++ 6 files changed, 149 insertions(+) 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 f8f6731d9a7..4018aec72c6 100644 --- a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java @@ -285,6 +285,17 @@ public TEntry tryGetOrCreate(@Nullable K key, @Nonnull CreateStrategy return created; } + /** + * {@link Maybe}-wrapped form of {@link #tryGetOrCreate}, delegating to it as the sole {@link + * Maybe#of} call site -- see {@link Maybe}'s class javadoc for why that shape is required to + * stay allocation-free. A growable table's {@link Maybe} is always present. + */ + @Nonnull + public Maybe tryGetOrCreateAsMaybe( + @Nullable K key, @Nonnull CreateStrategy createStrat) { + return Maybe.of(tryGetOrCreate(key, createStrat)); + } + /** * Unconditionally adds {@code entry} ({@code true}), or {@code false} if a fixed table is full. * Comparison-free and caller-responsible (same contract as {@link FlatHashtable#insert}): the @@ -476,6 +487,18 @@ public TEntry tryGetOrCreate( return created; } + /** + * Two-key analogue of {@link D1#tryGetOrCreateAsMaybe}: {@link Maybe}-wrapped form of {@link + * #tryGetOrCreate}, delegating to it as the sole {@link Maybe#of} call site. + */ + @Nonnull + public Maybe tryGetOrCreateAsMaybe( + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull CreateStrategy2 createStrat) { + return Maybe.of(tryGetOrCreate(key1, key2, createStrat)); + } + /** * Unconditionally adds {@code entry} ({@code true}), or {@code false} if a fixed table is full. * Comparison-free and caller-responsible: the caller must ensure {@code (key1, key2)} is 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 0e0b4352130..c0c1d79a54e 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -339,6 +339,23 @@ public TEntry tryGetOrCreate( return newEntry; } + /** + * {@link Maybe}-wrapped form of {@link #tryGetOrCreate}, for callers that want to guard the + * refused-create case with {@link Maybe#update} rather than a manual null check: + * + *

    {@code
    +     * table.tryGetOrCreateAsMaybe(key, Counter::new).update(n, ADD);
    +     * }
    + * + *

    Exactly one {@link Maybe#of} call site, fed by delegating to {@link #tryGetOrCreate} -- + * see {@link Maybe}'s class javadoc for why that shape is required to stay allocation-free. + */ + @Nonnull + public Maybe tryGetOrCreateAsMaybe( + @Nullable K key, @Nonnull Function creator) { + return Maybe.of(tryGetOrCreate(key, creator)); + } + /** * {@link #tryGetOrCreate} followed by {@code updater}, returning whether the update happened. * @@ -653,6 +670,18 @@ public TEntry tryGetOrCreate( return newEntry; } + /** + * Two-key analogue of {@link D1#tryGetOrCreateAsMaybe}: {@link Maybe}-wrapped form of {@link + * #tryGetOrCreate}, delegating to it as the sole {@link Maybe#of} call site. + */ + @Nonnull + public Maybe tryGetOrCreateAsMaybe( + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull BiFunction creator) { + return Maybe.of(tryGetOrCreate(key1, key2, creator)); + } + /** * Two-key analogue of {@link D1#tryGetOrUpdate(Object, Function, Consumer)}: applies {@code * updater} to the entry for {@code (key1, key2)}, creating one if absent, and returns whether 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 e51462451aa..b8ec29db704 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java @@ -257,6 +257,27 @@ void fixedGetOrCreateCapsWhenFull() { assertSame(a, table.tryGetOrCreate("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.tryGetOrCreateAsMaybe("c", k -> new StringIntEntry(k, 3)).isPresent()); + + Maybe hit = table.tryGetOrCreateAsMaybe("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.tryGetOrCreateAsMaybe("k" + i, k -> new StringIntEntry(k, 0)).isPresent()); + } + assertEquals(50, table.size()); + } + @Test void fixedInsertReturnsFalseWhenFull() { FlatHashtable.D1 table = fixed(2); 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 0900617035e..289d564fe59 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java @@ -226,6 +226,31 @@ void fixedGetOrCreateCapsWhenFull() { assertSame(a, table.tryGetOrCreate("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.tryGetOrCreateAsMaybe("c", 3, (k1, k2) -> new PairEntry(k1, k2, 3)).isPresent()); + + Maybe hit = + table.tryGetOrCreateAsMaybe("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.tryGetOrCreateAsMaybe("k", i, (k1, k2) -> new PairEntry(k1, k2, k2)); + assertTrue(maybe.isPresent()); + } + assertEquals(50, table.size()); + } + @Test void fixedInsertReturnsFalseWhenFull() { FlatHashtable.D2 table = fixed(2); 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 2c1a28b4bf4..24c95b7fb3e 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -245,6 +245,42 @@ void getOrCreateNullKeyIsPermitted() { assertEquals(1, table.size()); } + @Test + void getOrCreateAsMaybeOnMissBuildsEntryViaCreator() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + Maybe maybe = + table.tryGetOrCreateAsMaybe("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.createCapped(StringIntEntry.class, 2); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + + assertFalse(table.tryGetOrCreateAsMaybe("c", k -> new StringIntEntry(k, 3)).isPresent()); + assertEquals(2, table.size()); + + Maybe hit = table.tryGetOrCreateAsMaybe("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.createCapped(StringIntEntry.class, 1); + table.insert(new StringIntEntry("a", 1)); + + ObjLongConsumer add = (e, n) -> e.value += n; + table.tryGetOrCreateAsMaybe("a", k -> new StringIntEntry(k, 0)).update(5L, add); + assertEquals(6, table.get("a").value); + + table.tryGetOrCreateAsMaybe("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.createCapped(StringIntEntry.class, 2); 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 16739c4a9a5..8af22c9256a 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java @@ -206,6 +206,21 @@ void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { assertEquals(100, hit.value, "existing entry is still returned even at capacity"); } + @Test + void getOrCreateAsMaybeReturnsAbsentOnceAtCapacityButStillReturnsHits() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); + table.insert(new PairEntry("a", 1, 100)); + table.insert(new PairEntry("b", 2, 200)); + + assertFalse( + table.tryGetOrCreateAsMaybe("c", 3, (k1, k2) -> new PairEntry(k1, k2, 300)).isPresent()); + assertEquals(2, table.size()); + + Maybe hit = + table.tryGetOrCreateAsMaybe("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.createCapped(PairEntry.class, 2); From 4e5dabdbaec688ca85110f31e0ca93d1c3ab3f0f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 14:27:03 -0400 Subject: [PATCH 35/41] Promote tryGetOrCreateAsMaybe to tryGetOrCreate, demote nullable form to tryGetOrCreateOrNull Maybe becomes the primary get-or-create contract on Hashtable.D1/D2 and FlatHashtable.D1/D2; the raw nullable form survives as an escape hatch under a less-prominent name. Breaking change is affordable now: CardinalityLimitReporter is the only production caller and is updated to the renamed OrNull method here (the fused Maybe-based conversion lands separately in #12312). --- .../metrics/CardinalityLimitReporter.java | 2 +- .../datadog/trace/util/FlatHashtable.java | 88 +++++++------ .../java/datadog/trace/util/Hashtable.java | 124 +++++++++--------- .../main/java/datadog/trace/util/Maybe.java | 7 +- .../trace/util/FlatHashtableD1Test.java | 20 +-- .../trace/util/FlatHashtableD2Test.java | 23 ++-- .../datadog/trace/util/HashtableD1Test.java | 23 ++-- .../datadog/trace/util/HashtableD2Test.java | 14 +- 8 files changed, 151 insertions(+), 150 deletions(-) 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 fc64b9015d7..3b13a8800bd 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 @@ -58,7 +58,7 @@ 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) { - TagBlockEntry entry = blockedByTag.tryGetOrCreate(tag, TagBlockEntry::new); + TagBlockEntry entry = blockedByTag.tryGetOrCreateOrNull(tag, TagBlockEntry::new); if (entry != null) { entry.count += count; } 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 4018aec72c6..6b78c1c4539 100644 --- a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java @@ -123,12 +123,12 @@ protected Entry(long hash) { * *

    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 - * #tryGetOrCreate} caps and returns {@code null} (the caller supplies the overflow default). - * {@link #createGrowable} trades that for {@code HashMap}-like ergonomics — its {@code + * #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 tryGetOrCreate} never returns {@code null}. The distinct factory names make - * the choice explicit at the call site (there's no ambiguous {@code (Class, int)} constructor); - * {@code Capacity} always counts entries, matching the chained {@code + * 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.createCapped}. Across the family a table factory's number is always entries — only * the low-level array allocators take a bucket count. * @@ -197,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 #tryGetOrCreate} returns {@code null}). + * #DEFAULT_LOAD_FACTOR}, then capping ({@link #tryGetOrCreateOrNull} returns {@code null}). */ @Nonnull public static > D1 createFixed( @@ -257,18 +257,33 @@ 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. The name has to serve both postures, since the posture is chosen per instance at the - * factory while the method name is per class, and the two mistakes are not symmetric: - * under-promising refusal costs an NPE at the cap, over-promising it costs a redundant null - * check. So it errs toward {@code try}. + * 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}. */ @Nullable - public TEntry tryGetOrCreate(@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; @@ -285,17 +300,6 @@ public TEntry tryGetOrCreate(@Nullable K key, @Nonnull CreateStrategy return created; } - /** - * {@link Maybe}-wrapped form of {@link #tryGetOrCreate}, delegating to it as the sole {@link - * Maybe#of} call site -- see {@link Maybe}'s class javadoc for why that shape is required to - * stay allocation-free. A growable table's {@link Maybe} is always present. - */ - @Nonnull - public Maybe tryGetOrCreateAsMaybe( - @Nullable K key, @Nonnull CreateStrategy createStrat) { - return Maybe.of(tryGetOrCreate(key, createStrat)); - } - /** * Unconditionally adds {@code entry} ({@code true}), or {@code false} if a fixed table is full. * Comparison-free and caller-responsible (same contract as {@link FlatHashtable#insert}): the @@ -404,7 +408,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 #tryGetOrCreate} returns {@code null}). + * #DEFAULT_LOAD_FACTOR}, then capping ({@link #tryGetOrCreateOrNull} returns {@code null}). */ @Nonnull public static > D2 createFixed( @@ -463,11 +467,25 @@ public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { } /** - * Two-key analogue of {@link D1#tryGetOrCreate}: 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. */ @Nullable - public TEntry tryGetOrCreate( + public TEntry tryGetOrCreateOrNull( @Nullable K1 key1, @Nullable K2 key2, @Nonnull CreateStrategy2 createStrat) { @@ -487,18 +505,6 @@ public TEntry tryGetOrCreate( return created; } - /** - * Two-key analogue of {@link D1#tryGetOrCreateAsMaybe}: {@link Maybe}-wrapped form of {@link - * #tryGetOrCreate}, delegating to it as the sole {@link Maybe#of} call site. - */ - @Nonnull - public Maybe tryGetOrCreateAsMaybe( - @Nullable K1 key1, - @Nullable K2 key2, - @Nonnull CreateStrategy2 createStrat) { - return Maybe.of(tryGetOrCreate(key1, key2, createStrat)); - } - /** * Unconditionally adds {@code entry} ({@code true}), or {@code false} if a fixed table is full. * Comparison-free and caller-responsible: the caller must ensure {@code (key1, key2)} is 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 c0c1d79a54e..12ea5088eff 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -99,8 +99,8 @@ public final TEntry next() { * *

    Capacity is fixed at construction. The table does not resize, so the caller is responsible * for choosing a capacity appropriate to the working set. Once {@link #size()} reaches that - * capacity, {@link #insert} returns {@code false} and {@link #tryGetOrCreate} returns {@code - * null} rather than adding more entries -- a lookup hit is still always returned even at + * capacity, {@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#createCapped(int)} hands you a spine and a {@link SizeManager} already matched to @@ -172,8 +172,8 @@ private D1(int maxCapacity) { /** * A capped single-key table: it holds at most {@code maxCapacity} live entries, after - * which {@link #insert} returns {@code false} and {@link #tryGetOrCreate} returns {@code null}. - * A lookup hit is still always returned at capacity -- the cap only blocks new entries. + * 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 @@ -295,17 +295,16 @@ public boolean tryInsertOrReplace(@Nonnull TEntry newEntry) { } /** - * Returns the entry for {@code key}, building one via {@code creator} if absent -- or {@code - * null} if the key is absent and the table is at capacity. This method can refuse: - * despite the name it is not total, and a caller that dereferences the result without a null - * check will NPE the first time the cap is reached. A lookup hit is always returned even at - * capacity, so only the create half can fail. Check {@link #isFull()} beforehand if you want to - * distinguish "refused" from "created" without inspecting the result. + * 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 #createCapped}. Decide deliberately what a refused create should do (drop the sample, - * fall back, make room); silently ignoring the {@code null} turns the cap into data loss you - * cannot see. + * fall back, make room); silently ignoring an absent {@link Maybe} turns the cap into data loss + * you cannot see. * *

    Computes the hash once and reuses it for both the lookup and (on miss) the insert -- * avoids the double-hash that "{@code get}; if null then {@code insert}" would incur. @@ -314,9 +313,26 @@ public boolean tryInsertOrReplace(@Nonnull TEntry newEntry) { * 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. + * + *

    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}. + */ + @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 tryGetOrCreate( + public TEntry tryGetOrCreateOrNull( @Nullable K key, @Nonnull Function creator) { long keyHash = D1.Entry.hash(key); for (TEntry curEntry = bucketFor(this.buckets, keyHash); @@ -340,24 +356,8 @@ public TEntry tryGetOrCreate( } /** - * {@link Maybe}-wrapped form of {@link #tryGetOrCreate}, for callers that want to guard the - * refused-create case with {@link Maybe#update} rather than a manual null check: - * - *

    {@code
    -     * table.tryGetOrCreateAsMaybe(key, Counter::new).update(n, ADD);
    -     * }
    - * - *

    Exactly one {@link Maybe#of} call site, fed by delegating to {@link #tryGetOrCreate} -- - * see {@link Maybe}'s class javadoc for why that shape is required to stay allocation-free. - */ - @Nonnull - public Maybe tryGetOrCreateAsMaybe( - @Nullable K key, @Nonnull Function creator) { - return Maybe.of(tryGetOrCreate(key, creator)); - } - - /** - * {@link #tryGetOrCreate} followed by {@code updater}, returning whether the update happened. + * {@link #tryGetOrCreateOrNull} followed by {@code updater}, returning whether the update + * happened. * *

    Prefer this over the two-call form for the common read-modify-write shape -- a counter * bump, a max, a timestamp refresh: @@ -368,19 +368,19 @@ public Maybe tryGetOrCreateAsMaybe( * *

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

    No extra work versus doing it by hand -- the hash is still computed once, by the delegated - * {@link #tryGetOrCreate}. + * {@link #tryGetOrCreateOrNull}. */ public boolean tryGetOrUpdate( @Nullable K key, @Nonnull Function creator, @Nonnull Consumer updater) { - TEntry entry = tryGetOrCreate(key, creator); + TEntry entry = tryGetOrCreateOrNull(key, creator); if (entry == null) { return false; } @@ -399,7 +399,7 @@ public boolean tryGetOrUpdate( @Nonnull Function creator, C context, @Nonnull BiConsumer updater) { - TEntry entry = tryGetOrCreate(key, creator); + TEntry entry = tryGetOrCreateOrNull(key, creator); if (entry == null) { return false; } @@ -424,7 +424,7 @@ public boolean tryGetOrUpdate( @Nonnull Function creator, long context, @Nonnull ObjLongConsumer updater) { - TEntry entry = tryGetOrCreate(key, creator); + TEntry entry = tryGetOrCreateOrNull(key, creator); if (entry == null) { return false; } @@ -553,8 +553,8 @@ private D2(int maxCapacity) { /** * Composite-key analogue of {@link D1#createCapped}: a capped table holding at most * {@code maxCapacity} live entries, after which {@link #insert} returns {@code false} and - * {@link #tryGetOrCreate} returns {@code null}, with lookup hits still always returned. See - * {@link D1#createCapped} for what "capped" promises and why it is the default posture. + * {@link #tryGetOrCreateOrNull} returns {@code null}, with lookup hits still always returned. + * See {@link D1#createCapped} for what "capped" promises and why it is the default posture. * *

    {@code entryClass} is a type token only -- it pins the concrete entry type so the compiler * infers {@code K1}, {@code K2}, and {@code TEntry} at the call site (e.g. {@code @@ -635,17 +635,31 @@ public boolean tryInsertOrReplace(@Nonnull TEntry newEntry) { /** * Two-key analogue of {@link D1#tryGetOrCreate}: returns the entry for {@code (key1, key2)}, - * building one via {@code creator} if absent -- or {@code null} if the pair is absent and the - * table is at capacity. Like the single-key form it is not total despite the name, and - * refusal is a designed steady state rather than an exceptional one; see {@link - * D1#tryGetOrCreate} for the full contract and what to do about a refused create. + * 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#tryGetOrCreateOrNull}: low-level, {@code null}-returning form + * of {@link #tryGetOrCreate}. Prefer the {@link Maybe} form above for new call sites. */ @Nullable - public TEntry tryGetOrCreate( + public TEntry tryGetOrCreateOrNull( @Nullable K1 key1, @Nullable K2 key2, @Nonnull BiFunction creator) { @@ -670,31 +684,19 @@ public TEntry tryGetOrCreate( return newEntry; } - /** - * Two-key analogue of {@link D1#tryGetOrCreateAsMaybe}: {@link Maybe}-wrapped form of {@link - * #tryGetOrCreate}, delegating to it as the sole {@link Maybe#of} call site. - */ - @Nonnull - public Maybe tryGetOrCreateAsMaybe( - @Nullable K1 key1, - @Nullable K2 key2, - @Nonnull BiFunction creator) { - return Maybe.of(tryGetOrCreate(key1, key2, creator)); - } - /** * Two-key analogue of {@link D1#tryGetOrUpdate(Object, Function, Consumer)}: applies {@code * updater} to the entry for {@code (key1, key2)}, creating one if absent, and returns whether * the update happened. Returns {@code false} without updating when the pair is absent and the * table is at capacity. See the single-key form for why fusing the update is preferred over - * {@code tryGetOrCreate(...)} followed by a dereference. + * {@code tryGetOrCreateOrNull(...)} followed by a dereference. */ public boolean tryGetOrUpdate( @Nullable K1 key1, @Nullable K2 key2, @Nonnull BiFunction creator, @Nonnull Consumer updater) { - TEntry entry = tryGetOrCreate(key1, key2, creator); + TEntry entry = tryGetOrCreateOrNull(key1, key2, creator); if (entry == null) { return false; } @@ -714,7 +716,7 @@ public boolean tryGetOrUpdate( @Nonnull BiFunction creator, C context, @Nonnull BiConsumer updater) { - TEntry entry = tryGetOrCreate(key1, key2, creator); + TEntry entry = tryGetOrCreateOrNull(key1, key2, creator); if (entry == null) { return false; } 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 f69bc9784ca..a3e986174cf 100644 --- a/internal-api/src/main/java/datadog/trace/util/Maybe.java +++ b/internal-api/src/main/java/datadog/trace/util/Maybe.java @@ -82,10 +82,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) { 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 b8ec29db704..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.tryGetOrCreate( + table.tryGetOrCreateOrNull( "foo", k -> { createCount[0]++; @@ -209,7 +209,7 @@ void getOrCreateOnHitSkipsCreator() { table.insert(seeded); int[] createCount = {0}; StringIntEntry got = - table.tryGetOrCreate( + 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.tryGetOrCreate("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,15 @@ void growableGetOrCreateNeverReturnsNull() { @Test void fixedGetOrCreateCapsWhenFull() { FlatHashtable.D1 table = fixed(2); - assertNotNull(table.tryGetOrCreate("a", k -> new StringIntEntry(k, 1))); - assertNotNull(table.tryGetOrCreate("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.tryGetOrCreate("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.tryGetOrCreate("a", k -> new StringIntEntry(k, 99))); + assertSame(a, table.tryGetOrCreateOrNull("a", k -> new StringIntEntry(k, 99))); } @Test @@ -263,9 +263,9 @@ void fixedGetOrCreateAsMaybeIsAbsentWhenFullButStillReturnsHits() { table.tryGetOrCreate("a", k -> new StringIntEntry(k, 1)); table.tryGetOrCreate("b", k -> new StringIntEntry(k, 2)); - assertFalse(table.tryGetOrCreateAsMaybe("c", k -> new StringIntEntry(k, 3)).isPresent()); + assertFalse(table.tryGetOrCreate("c", k -> new StringIntEntry(k, 3)).isPresent()); - Maybe hit = table.tryGetOrCreateAsMaybe("a", k -> new StringIntEntry(k, 99)); + Maybe hit = table.tryGetOrCreate("a", k -> new StringIntEntry(k, 99)); assertEquals(1, hit.getOrNull().value, "existing entry is still returned even at capacity"); } @@ -273,7 +273,7 @@ void fixedGetOrCreateAsMaybeIsAbsentWhenFullButStillReturnsHits() { void growableGetOrCreateAsMaybeIsAlwaysPresent() { FlatHashtable.D1 table = growable(1); for (int i = 0; i < 50; i++) { - assertTrue(table.tryGetOrCreateAsMaybe("k" + i, k -> new StringIntEntry(k, 0)).isPresent()); + assertTrue(table.tryGetOrCreate("k" + i, k -> new StringIntEntry(k, 0)).isPresent()); } assertEquals(50, table.size()); } 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 289d564fe59..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.tryGetOrCreate( + table.tryGetOrCreateOrNull( "foo", 1, (k1, k2) -> { @@ -190,7 +190,7 @@ void getOrCreateOnHitSkipsCreator() { table.insert(seeded); int[] createCount = {0}; PairEntry got = - table.tryGetOrCreate( + table.tryGetOrCreateOrNull( "foo", 1, (k1, k2) -> { @@ -217,13 +217,13 @@ void growableGrowsPastInitialCapacity() { @Test void fixedGetOrCreateCapsWhenFull() { FlatHashtable.D2 table = fixed(2); - assertNotNull(table.tryGetOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 1))); - assertNotNull(table.tryGetOrCreate("b", 2, (k1, k2) -> new PairEntry(k1, k2, 2))); + 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.tryGetOrCreate("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.tryGetOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 99))); + assertSame(a, table.tryGetOrCreateOrNull("a", 1, (k1, k2) -> new PairEntry(k1, k2, 99))); } @Test @@ -232,11 +232,9 @@ void fixedGetOrCreateAsMaybeIsAbsentWhenFullButStillReturnsHits() { table.tryGetOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 1)); table.tryGetOrCreate("b", 2, (k1, k2) -> new PairEntry(k1, k2, 2)); - assertFalse( - table.tryGetOrCreateAsMaybe("c", 3, (k1, k2) -> new PairEntry(k1, k2, 3)).isPresent()); + assertFalse(table.tryGetOrCreate("c", 3, (k1, k2) -> new PairEntry(k1, k2, 3)).isPresent()); - Maybe hit = - table.tryGetOrCreateAsMaybe("a", 1, (k1, k2) -> new PairEntry(k1, k2, 99)); + 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"); } @@ -244,8 +242,7 @@ void fixedGetOrCreateAsMaybeIsAbsentWhenFullButStillReturnsHits() { void growableGetOrCreateAsMaybeIsAlwaysPresent() { FlatHashtable.D2 table = growable(1); for (int i = 0; i < 50; i++) { - Maybe maybe = - table.tryGetOrCreateAsMaybe("k", i, (k1, k2) -> new PairEntry(k1, k2, k2)); + Maybe maybe = table.tryGetOrCreate("k", i, (k1, k2) -> new PairEntry(k1, k2, k2)); assertTrue(maybe.isPresent()); } assertEquals(50, table.size()); @@ -283,7 +280,7 @@ void hashCollisionsResolveByKeyEquality() { void growableGetOrCreateGrowsPastInitialCapacity() { FlatHashtable.D2 table = growable(1); for (int i = 0; i < 50; i++) { - PairEntry e = table.tryGetOrCreate("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/HashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java index 24c95b7fb3e..9905642fa92 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -202,7 +202,7 @@ void getOrCreateOnMissBuildsEntryViaCreator() { Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); int[] createCount = {0}; StringIntEntry created = - table.tryGetOrCreate( + table.tryGetOrCreateOrNull( "foo", k -> { createCount[0]++; @@ -223,7 +223,7 @@ void getOrCreateOnHitSkipsCreator() { table.insert(seeded); int[] createCount = {0}; StringIntEntry got = - table.tryGetOrCreate( + table.tryGetOrCreateOrNull( "foo", k -> { createCount[0]++; @@ -237,19 +237,18 @@ void getOrCreateOnHitSkipsCreator() { @Test void getOrCreateNullKeyIsPermitted() { Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); - StringIntEntry created = table.tryGetOrCreate(null, k -> new StringIntEntry(k, 7)); + StringIntEntry created = table.tryGetOrCreateOrNull(null, k -> new StringIntEntry(k, 7)); assertNotNull(created); assertNull(created.key); assertEquals(7, created.value); - assertSame(created, table.tryGetOrCreate(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.createCapped(StringIntEntry.class, 8); - Maybe maybe = - table.tryGetOrCreateAsMaybe("foo", k -> new StringIntEntry(k, 42)); + Maybe maybe = table.tryGetOrCreate("foo", k -> new StringIntEntry(k, 42)); assertTrue(maybe.isPresent()); assertEquals(42, maybe.getOrNull().value); assertSame(table.get("foo"), maybe.getOrNull()); @@ -261,10 +260,10 @@ void getOrCreateAsMaybeReturnsAbsentOnceAtCapacityButStillReturnsHits() { table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); - assertFalse(table.tryGetOrCreateAsMaybe("c", k -> new StringIntEntry(k, 3)).isPresent()); + assertFalse(table.tryGetOrCreate("c", k -> new StringIntEntry(k, 3)).isPresent()); assertEquals(2, table.size()); - Maybe hit = table.tryGetOrCreateAsMaybe("a", k -> new StringIntEntry(k, 999)); + Maybe hit = table.tryGetOrCreate("a", k -> new StringIntEntry(k, 999)); assertEquals(1, hit.getOrNull().value, "existing entry is still returned even at capacity"); } @@ -274,10 +273,10 @@ void getOrCreateAsMaybeUpdateAppliesOnlyWhenPresent() { table.insert(new StringIntEntry("a", 1)); ObjLongConsumer add = (e, n) -> e.value += n; - table.tryGetOrCreateAsMaybe("a", k -> new StringIntEntry(k, 0)).update(5L, add); + table.tryGetOrCreate("a", k -> new StringIntEntry(k, 0)).update(5L, add); assertEquals(6, table.get("a").value); - table.tryGetOrCreateAsMaybe("b", k -> new StringIntEntry(k, 0)).update(5L, add); + table.tryGetOrCreate("b", k -> new StringIntEntry(k, 0)).update(5L, add); assertNull(table.get("b"), "refused create at capacity leaves nothing to update"); } @@ -297,10 +296,10 @@ void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); - assertNull(table.tryGetOrCreate("c", k -> new StringIntEntry(k, 3))); + assertNull(table.tryGetOrCreateOrNull("c", k -> new StringIntEntry(k, 3))); assertEquals(2, table.size()); - StringIntEntry hit = table.tryGetOrCreate("a", k -> new StringIntEntry(k, 999)); + StringIntEntry hit = table.tryGetOrCreateOrNull("a", k -> new StringIntEntry(k, 999)); assertEquals(1, hit.value, "existing entry is still returned even at capacity"); } 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 8af22c9256a..a7378f55904 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java @@ -85,7 +85,7 @@ void getOrCreateOnMissBuildsEntryViaCreator() { Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); int[] createCount = {0}; PairEntry created = - table.tryGetOrCreate( + table.tryGetOrCreateOrNull( "a", 1, (k1, k2) -> { @@ -108,7 +108,7 @@ void getOrCreateOnHitSkipsCreator() { table.insert(seeded); int[] createCount = {0}; PairEntry got = - table.tryGetOrCreate( + table.tryGetOrCreateOrNull( "a", 1, (k1, k2) -> { @@ -199,10 +199,10 @@ void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); - assertNull(table.tryGetOrCreate("c", 3, (k1, k2) -> new PairEntry(k1, k2, 300))); + assertNull(table.tryGetOrCreateOrNull("c", 3, (k1, k2) -> new PairEntry(k1, k2, 300))); assertEquals(2, table.size()); - PairEntry hit = table.tryGetOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 999)); + 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"); } @@ -212,12 +212,10 @@ void getOrCreateAsMaybeReturnsAbsentOnceAtCapacityButStillReturnsHits() { table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); - assertFalse( - table.tryGetOrCreateAsMaybe("c", 3, (k1, k2) -> new PairEntry(k1, k2, 300)).isPresent()); + assertFalse(table.tryGetOrCreate("c", 3, (k1, k2) -> new PairEntry(k1, k2, 300)).isPresent()); assertEquals(2, table.size()); - Maybe hit = - table.tryGetOrCreateAsMaybe("a", 1, (k1, k2) -> new PairEntry(k1, k2, 999)); + 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"); } From fe71bfe0170d94a6c10e5b2e3c53e0a7590832b7 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 21:49:30 -0400 Subject: [PATCH 36/41] Tighten Maybe.update's primitive-overload javadoc Co-Authored-By: Claude Sonnet 5 --- .../main/java/datadog/trace/util/Maybe.java | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) 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 a3e986174cf..fc941cd872e 100644 --- a/internal-api/src/main/java/datadog/trace/util/Maybe.java +++ b/internal-api/src/main/java/datadog/trace/util/Maybe.java @@ -113,20 +113,13 @@ public void update(C context, BiConsumer mutator) { * once per mutator-flavor per table type -- see {@code Hashtable#tryGetOrUpdate}'s {@code * ObjLongConsumer} overload for the caller-side problem this replaces. * - *

    Deliberately the only primitive-context overload of {@code update}. An {@code - * int}/{@code boolean} sibling was tried and reverted: Java's overload resolution can pick - * cleanly between a primitive overload and the generic {@link #update(Object, BiConsumer)} form - * for a reference-typed argument (boxing is only considered once no non-boxing candidate - * applies), but that guarantee does not extend to a second primitive overload -- {@code update(1, - * lambda)} is ambiguous between {@code int} and {@code long} even with no {@code double} overload - * in the picture, because {@link ObjIntConsumer} and {@link ObjLongConsumer} are unrelated - * interfaces and JLS 15.12.2.5's most-specific-method rule requires every parameter position to - * agree, not just the numeric one. Confirmed by direct compilation, not just JLS reading: an - * inline lambda call breaks as soon as a second primitive overload exists. A plain {@code int} - * argument still widens to {@code long} for free at this single overload -- callers are not - * required to have a {@code long} in hand. {@code double} context is rare enough not to bother - * keeping pretty -- see {@link #updateDouble} for that case, given its own name to sidestep the - * ambiguity rather than trying to squeeze it into an overload. + *

    Deliberately the only primitive-context overload of {@code update}. A second one + * (e.g. {@code int}) was tried and reverted: with two primitive overloads, {@code update(1, + * lambda)} becomes ambiguous between them at an inline-lambda call site, since {@link + * ObjIntConsumer} and {@link ObjLongConsumer} are unrelated interfaces -- confirmed by direct + * compilation. A plain {@code int} argument still widens to {@code long} for free here, so + * callers without a {@code long} in hand are unaffected. {@code double} context is rare enough to + * get its own name instead -- see {@link #updateDouble} -- rather than risk that ambiguity. */ public void update(long context, ObjLongConsumer mutator) { if (value != null) { From a265873b3847ea7c4c5b110145b57a57c0ac9956 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 15:08:23 -0400 Subject: [PATCH 37/41] Rename Hashtable.createCapped to createBounded for family-wide naming consistency Aligns with FlatHashtable's vocabulary and fixes a stale doc cross-reference in FlatHashtable's javadoc. --- .../metrics/CardinalityLimitReporter.java | 2 +- .../trace/util/HashtableD1Benchmark.java | 2 +- .../trace/util/HashtableD2Benchmark.java | 2 +- .../datadog/trace/util/FlatHashtable.java | 4 +- .../java/datadog/trace/util/Hashtable.java | 26 +++---- .../datadog/trace/util/HashtableTest.java | 74 +++++++++---------- 6 files changed, 55 insertions(+), 55 deletions(-) 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 3b13a8800bd..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 @@ -45,7 +45,7 @@ final class CardinalityLimitReporter { private final RatelimitedLogger rlLog; // Tag name -> blocked count accumulated since the last emitted summary. private final Hashtable.D1 blockedByTag = - Hashtable.D1.createCapped(TagBlockEntry.class, TAG_CAPACITY); + Hashtable.D1.createBounded(TagBlockEntry.class, TAG_CAPACITY); CardinalityLimitReporter() { this(new RatelimitedLogger(log, 5, MINUTES)); 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 f9bcb0de96e..9d4bf98b041 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java @@ -178,7 +178,7 @@ public static class D1State { public void setUp() { BenchmarkUtils.polluteHashDispatch(); - table = Hashtable.D1.createCapped(D1Counter.class, 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 4f233b8524b..a2e43c6b147 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java @@ -186,7 +186,7 @@ public static class D2State { public void setUp() { BenchmarkUtils.polluteHashDispatch(); - table = Hashtable.D2.createCapped(D2Counter.class, CAPACITY); + table = Hashtable.D2.createBounded(D2Counter.class, CAPACITY); hashMap = new HashMap<>(CAPACITY); k1s = SOURCE_K1; k2s = SOURCE_K2; diff --git a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java index 6b78c1c4539..49f8c08678b 100644 --- a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java @@ -129,8 +129,8 @@ protected Entry(long hash) { * 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.createCapped}. Across the family a table factory's number is always entries — only - * the low-level array allocators take a bucket count. + * 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 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 12ea5088eff..d596a9da3d7 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -103,7 +103,7 @@ public final TEntry next() { * {@code null} rather than adding more entries -- a lookup hit is still always returned even at * capacity, the cap only blocks new entries. Want your own eviction policy instead of a hard cap? * Drop down to the static building blocks and drive the bucket array yourself -- {@link - * Hashtable#createCapped(int)} hands you a spine and a {@link SizeManager} already matched to + * 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. * @@ -181,7 +181,7 @@ private D1(int maxCapacity) { * it, a bounded footprint -- the posture an agent living in someone else's heap wants by * default. Callers that need overflow to be absorbed rather than refused should pair a {@link * SizeManager}'s eviction half over the static building blocks (see {@link - * Hashtable#createCapped(int)}) rather than reaching for an uncapped table. + * 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. @@ -192,14 +192,14 @@ private D1(int maxCapacity) { * *

    {@code entryClass} is a type token only -- it pins the concrete entry type so the compiler * infers both {@code K} and {@code TEntry} at the call site (e.g. {@code - * D1.createCapped(MyEntry.class, 64)}), keeping the factory symmetric with the rest of the + * 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. */ @Nonnull - public static > D1 createCapped( + public static > D1 createBounded( @Nonnull Class entryClass, int maxCapacity) { return new D1<>(maxCapacity); } @@ -302,7 +302,7 @@ public boolean tryInsertOrReplace(@Nonnull TEntry newEntry) { * inspecting the result. * *

    Refusal is a designed steady state for a capped table, not an exceptional condition -- see - * {@link #createCapped}. Decide deliberately what a refused create should do (drop the sample, + * {@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. * @@ -551,20 +551,20 @@ private D2(int maxCapacity) { } /** - * Composite-key analogue of {@link D1#createCapped}: a capped table holding at most + * 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#createCapped} for what "capped" promises and why it is the default posture. + * 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.createCapped(MyEntry.class, 64)}). Unlike {@link Hashtable#create(Class, int)} it is not + * 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. */ @Nonnull - public static > D2 createCapped( + public static > D2 createBounded( @Nonnull Class entryClass, int maxCapacity) { return new D2<>(maxCapacity); } @@ -787,7 +787,7 @@ public void drain(C context, @Nonnull BiConsumer * *

    {@code capacity} sizes the bucket array 1:1 (no headroom) -- chains stay a plain hash table * at exactly this many entries. For load-factor headroom over a target cap on live entries (so - * chains stay short even as the table fills, the way {@link D1}/{@link D2}/{@link #createCapped} + * 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))}. */ @@ -803,7 +803,7 @@ public static TEntry[] create( * rounded up to the next power of two, with the base {@code Hashtable.Entry[]} component type. * *

    Use this when the spine is driven purely through the static building blocks, which all take - * {@code Hashtable.Entry[]} -- that is what {@link D1}, {@link D2}, and {@link #createCapped} + * {@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 @@ -830,7 +830,7 @@ public static Hashtable.Entry[] create(int buckets) { * Bucket-array length for a strict cap of {@code cardinalityLimit} live entries at {@link * #DEFAULT_LOAD_FACTOR}: infers a reasonable bucket count from the entry cap you actually care * about, rather than making every caller redo the headroom math ({@link D1}, {@link D2}, and - * {@link #createCapped} all size themselves this way). Pair with a {@link SizeManager} of {@code + * {@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) { @@ -1495,7 +1495,7 @@ private State(Hashtable.Entry[] buckets, int maxCapacity) { * maxCapacity}, paired with a {@link SizeManager} capped at the strict {@code maxCapacity}. */ @Nonnull - public static State createCapped(int maxCapacity) { + public static State createBounded(int maxCapacity) { Hashtable.Entry[] buckets = create(capacityFor(maxCapacity)); return new State<>(buckets, maxCapacity); } 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 8b278f5428c..1b4a5aa6684 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -160,7 +160,7 @@ void bucketIndexIsBoundedByArrayLength() { @Test void clearNullsAllBuckets() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createBounded(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("x", 1); buckets[1] = new StringIntEntry("y", 2); @@ -172,7 +172,7 @@ void clearNullsAllBuckets() { @Test void drainVisitsEveryEntryThenClears() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createBounded(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("x", 1); buckets[1] = new StringIntEntry("y", 2); @@ -188,7 +188,7 @@ void drainVisitsEveryEntryThenClears() { @Test void insertHeadEntrySplicesAsNewHead() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createBounded(4); Hashtable.Entry[] buckets = table.buckets; StringIntEntry a = new StringIntEntry("a", 1); StringIntEntry b = new StringIntEntry("b", 2); @@ -205,7 +205,7 @@ void insertHeadEntrySplicesAsNewHead() { @Test void insertHeadEntryOfAlreadyLinkedEntryTripsAssertion() { assumeTrue(assertionsEnabled(), "assert-guard test requires -ea"); - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createBounded(4); Hashtable.Entry[] buckets = table.buckets; StringIntEntry a = new StringIntEntry("a", 1); StringIntEntry b = new StringIntEntry("b", 2); @@ -366,7 +366,7 @@ void walksOnlyMatchingHash() { // 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 = - Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); + Hashtable.D1.createBounded(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -386,7 +386,7 @@ void walksOnlyMatchingHash() { @Test void exhaustedIteratorThrowsNoSuchElement() { Hashtable.D1 table = - Hashtable.D1.createCapped(StringIntEntry.class, 4); + Hashtable.D1.createBounded(StringIntEntry.class, 4); table.insert(new StringIntEntry("only", 1)); long h = Hashtable.D1.Entry.hash("only"); BucketIterator it = Hashtable.bucketIterator(table.buckets, h); @@ -405,7 +405,7 @@ class MutatingBucketIteratorTests { void removeFromHeadOfChainUnlinks() { // Make three entries with the same hash so they chain in one bucket Hashtable.D1 table = - Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); + Hashtable.D1.createBounded(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -439,7 +439,7 @@ void removeFromHeadOfChainUnlinks() { @Test void replaceSwapsEntryAndPreservesChain() { Hashtable.D1 table = - Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); + Hashtable.D1.createBounded(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKeyEntry e1 = new CollidingKeyEntry(k1, 1); @@ -462,7 +462,7 @@ void replaceSwapsEntryAndPreservesChain() { @Test void removeWithoutNextThrows() { Hashtable.D1 table = - Hashtable.D1.createCapped(StringIntEntry.class, 4); + Hashtable.D1.createBounded(StringIntEntry.class, 4); table.insert(new StringIntEntry("a", 1)); MutatingBucketIterator it = Hashtable.mutatingBucketIterator(table.buckets, Hashtable.D1.Entry.hash("a")); @@ -478,7 +478,7 @@ class MutatingTableIteratorTests { @Test void walksEveryEntryAcrossBuckets() { Hashtable.D1 table = - Hashtable.D1.createCapped(StringIntEntry.class, 16); + Hashtable.D1.createBounded(StringIntEntry.class, 16); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); table.insert(new StringIntEntry("c", 3)); @@ -498,7 +498,7 @@ void walksEveryEntryAcrossBuckets() { @Test void emptyTableIteratorIsExhausted() { Hashtable.D1 table = - Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1.createBounded(StringIntEntry.class, 8); MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); assertFalse(it.hasNext()); assertThrows(NoSuchElementException.class, it::next); @@ -507,7 +507,7 @@ void emptyTableIteratorIsExhausted() { @Test void removeUnlinksBucketHead() { Hashtable.D1 table = - Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); + Hashtable.D1.createBounded(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); table.insert(new CollidingKeyEntry(k1, 1)); @@ -527,7 +527,7 @@ void removeUnlinksBucketHead() { @Test void removeUnlinksMidChainEntry() { Hashtable.D1 table = - Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); + Hashtable.D1.createBounded(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -563,7 +563,7 @@ void removeSkipsOverEmptyBuckets() { // makes empty buckets between them very likely). Verify the iterator skips empties cleanly // after a remove. Hashtable.D1 table = - Hashtable.D1.createCapped(StringIntEntry.class, 64); + Hashtable.D1.createBounded(StringIntEntry.class, 64); table.insert(new StringIntEntry("alpha", 1)); table.insert(new StringIntEntry("beta", 2)); table.insert(new StringIntEntry("gamma", 3)); @@ -582,7 +582,7 @@ void removeSkipsOverEmptyBuckets() { @Test void removeWithoutNextThrows() { Hashtable.D1 table = - Hashtable.D1.createCapped(StringIntEntry.class, 4); + Hashtable.D1.createBounded(StringIntEntry.class, 4); table.insert(new StringIntEntry("a", 1)); MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); assertThrows(IllegalStateException.class, it::remove); @@ -591,7 +591,7 @@ void removeWithoutNextThrows() { @Test void removeTwiceWithoutInterveningNextThrows() { Hashtable.D1 table = - Hashtable.D1.createCapped(StringIntEntry.class, 4); + Hashtable.D1.createBounded(StringIntEntry.class, 4); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); @@ -606,7 +606,7 @@ void halfOpenRangeOmitsBucketsOutsideTheRange() { // table -> bucketIndex = hash & 15. Place entries in buckets 0, 5, and 10; iterate // [5, 10) -- should see only bucket 5. Hashtable.D1 table = - Hashtable.D1.createCapped(CollidingKeyEntry.class, 16); + 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)); @@ -626,7 +626,7 @@ 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 = - Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1.createBounded(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets, 0, 0); @@ -636,7 +636,7 @@ void emptyHalfOpenRangeIsExhausted() { @Test void rangeBoundsOutOfOrderThrows() { Hashtable.D1 table = - Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1.createBounded(StringIntEntry.class, 8); assertThrows( IndexOutOfBoundsException.class, () -> Hashtable.mutatingTableIterator(table.buckets, -1, 4)); @@ -655,7 +655,7 @@ 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 = - Hashtable.D1.createCapped(CollidingKeyEntry.class, 16); + Hashtable.D1.createBounded(CollidingKeyEntry.class, 16); table.insert(new CollidingKeyEntry(new CollidingKey("b3", 3), 1)); MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); @@ -672,7 +672,7 @@ class EvictionTests { @Test void evictOneRemovesFirstMatchAndAdvancesCursor() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createBounded(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("a", 1); buckets[1] = new StringIntEntry("b", 2); @@ -685,7 +685,7 @@ void evictOneRemovesFirstMatchAndAdvancesCursor() { @Test void tryReserveOrEvictReservesWhileRoomRemains() { - Hashtable.State table = Hashtable.createCapped(2); + Hashtable.State table = Hashtable.createBounded(2); assertTrue(Hashtable.tryReserveOrEvict(table, e -> e.value == 0)); assertTrue(Hashtable.tryReserveOrEvict(table, e -> e.value == 0)); @@ -694,7 +694,7 @@ void tryReserveOrEvictReservesWhileRoomRemains() { @Test void tryReserveOrEvictMakesRoomWhenFull() { - Hashtable.State table = Hashtable.createCapped(2); + 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)); @@ -712,7 +712,7 @@ void tryReserveOrEvictMakesRoomWhenFull() { @Test void tryReserveOrEvictRefusesWhenFullAndNothingEvictable() { - Hashtable.State table = Hashtable.createCapped(1); + Hashtable.State table = Hashtable.createBounded(1); StringIntEntry hot = new StringIntEntry("hot", 1); assertTrue(Hashtable.insertHeadEntryFor(table, hot.keyHash, hot)); @@ -725,7 +725,7 @@ void tryReserveOrEvictRefusesWhenFullAndNothingEvictable() { @Test void removeMatchingOverStateNeedsNoTypeWitness() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createBounded(4); StringIntEntry a = new StringIntEntry("a", 1); Hashtable.insertHeadEntryFor(table, a.keyHash, a); @@ -737,7 +737,7 @@ void removeMatchingOverStateNeedsNoTypeWitness() { @Test void stateAccessorsAndInsertReservedRoundTrip() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createBounded(4); assertTrue(Hashtable.isLikelyEmpty(table)); assertEquals(0, Hashtable.estimateSize(table)); @@ -763,7 +763,7 @@ void stateAccessorsAndInsertReservedRoundTrip() { @Test void clearOverStateEmptiesSpineAndResetsCount() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createBounded(4); StringIntEntry a = new StringIntEntry("a", 1); Hashtable.insertHeadEntryFor(table, a.keyHash, a); assertEquals(1, table.sizeManager.estimateSize()); @@ -776,7 +776,7 @@ void clearOverStateEmptiesSpineAndResetsCount() { @Test void evictOneReturnsNullWhenNothingMatches() { - Hashtable.State table = Hashtable.createCapped(4); + 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)); @@ -785,7 +785,7 @@ void evictOneReturnsNullWhenNothingMatches() { @Test void evictAllKeepsCountConsistentWhenThePredicateThrows() { - Hashtable.State table = Hashtable.createCapped(8); + 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)); @@ -815,7 +815,7 @@ void evictAllKeepsCountConsistentWhenThePredicateThrows() { @Test void drainDetachesEntriesSoASinkCannotPinTheChain() { // Two entries forced into one bucket, so the drained pair is chained. - Hashtable.State table = Hashtable.createCapped(4); + 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)); @@ -833,7 +833,7 @@ void drainDetachesEntriesSoASinkCannotPinTheChain() { @Test void evictOneAdvancesCursorEvenWhenNothingMatches() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createBounded(4); StringIntEntry a = new StringIntEntry("a", 1); assertTrue(Hashtable.insertHeadEntryFor(table, a.keyHash, a)); @@ -850,7 +850,7 @@ void evictOneAdvancesCursorEvenWhenNothingMatches() { @Test void evictOneWrapsAroundToStartOfTable() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createBounded(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("a", 1); buckets[3] = new StringIntEntry("d", 4); @@ -865,7 +865,7 @@ void evictOneWrapsAroundToStartOfTable() { @Test void drainRemovesAllMatchesAndResetsCursor() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createBounded(4); Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("a", 1); buckets[1] = new StringIntEntry("b", 2); @@ -886,7 +886,7 @@ void drainRemovesAllMatchesAndResetsCursor() { @Test void resetZeroesCursor() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createBounded(4); Hashtable.Entry[] buckets = table.buckets; buckets[3] = new StringIntEntry("d", 4); Hashtable.evictOne(table, e -> e.value == 4); @@ -906,7 +906,7 @@ class StateTests { @Test void createTableSizesBucketsWithHeadroomAndCapsSize() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createBounded(4); int len = table.buckets.length; assertTrue(len >= 4, "backing array must have load-factor headroom over capacity"); @@ -919,7 +919,7 @@ void createTableSizesBucketsWithHeadroomAndCapsSize() { @Test void tableSizeTrackerRespectsCapacity() { - Hashtable.State table = Hashtable.createCapped(1); + Hashtable.State table = Hashtable.createBounded(1); assertTrue(table.sizeManager.tryReserve()); assertTrue(table.sizeManager.isFull()); @@ -928,7 +928,7 @@ void tableSizeTrackerRespectsCapacity() { @Test void tableSizeManagerOperatesOnItsOwnBuckets() { - Hashtable.State table = Hashtable.createCapped(4); + Hashtable.State table = Hashtable.createBounded(4); table.buckets[0] = new StringIntEntry("a", 1); StringIntEntry evicted = From f338f90c49885881dba50878a639f6aac30b8712 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 15:09:22 -0400 Subject: [PATCH 38/41] Rename createCapped to createBounded in Hashtable D1/D2 tests Follow-up to the production/javadoc rename, missed because these test files had uncommitted local edits at the time. --- .../datadog/trace/util/HashtableD1Test.java | 105 ++++++++++++------ .../datadog/trace/util/HashtableD2Test.java | 44 ++++---- 2 files changed, 91 insertions(+), 58 deletions(-) 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 9905642fa92..45b994b7688 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -19,14 +19,16 @@ class HashtableD1Test { @Test void emptyTableLookupReturnsNull() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); assertNull(table.get("missing")); assertEquals(0, table.size()); } @Test void insertedEntryIsRetrievable() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); StringIntEntry e = new StringIntEntry("foo", 1); table.insert(e); assertEquals(1, table.size()); @@ -42,7 +44,7 @@ void keyExposesTheConstructionKey() { @Test void multipleInsertsRetrievableSeparately() { Hashtable.D1 table = - Hashtable.D1.createCapped(StringIntEntry.class, 16); + Hashtable.D1.createBounded(StringIntEntry.class, 16); StringIntEntry a = new StringIntEntry("alpha", 1); StringIntEntry b = new StringIntEntry("beta", 2); StringIntEntry c = new StringIntEntry("gamma", 3); @@ -57,7 +59,8 @@ void multipleInsertsRetrievableSeparately() { @Test void inPlaceMutationVisibleViaSubsequentGet() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 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"); @@ -68,7 +71,8 @@ void inPlaceMutationVisibleViaSubsequentGet() { @Test void removeUnlinksEntryAndDecrementsSize() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 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()); @@ -83,7 +87,8 @@ void removeUnlinksEntryAndDecrementsSize() { @Test void removeNonexistentReturnsNullAndDoesNotChangeSize() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); assertNull(table.remove("nope")); assertEquals(1, table.size()); @@ -91,7 +96,8 @@ void removeNonexistentReturnsNullAndDoesNotChangeSize() { @Test void tryInsertOrReplaceInsertsThenReplacesWithoutGrowing() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); StringIntEntry first = new StringIntEntry("k", 1); assertTrue(table.tryInsertOrReplace(first), "fresh insert accepted"); assertEquals(1, table.size()); @@ -104,7 +110,8 @@ void tryInsertOrReplaceInsertsThenReplacesWithoutGrowing() { @Test void clearEmptiesTheTable() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); table.clear(); @@ -117,7 +124,8 @@ void clearEmptiesTheTable() { @Test void forEachVisitsEveryInsertedEntry() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 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)); @@ -131,7 +139,8 @@ void forEachVisitsEveryInsertedEntry() { @Test void forEachWithContextPassesContextToConsumer() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 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)); @@ -145,7 +154,8 @@ void forEachWithContextPassesContextToConsumer() { @Test void forEachWithContextOnEmptyTableDoesNothing() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 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()); @@ -153,7 +163,8 @@ void forEachWithContextOnEmptyTableDoesNothing() { @Test void nullKeyIsPermittedAndDistinctFromAbsent() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); assertNull(table.get(null)); StringIntEntry nullKeyed = new StringIntEntry(null, 7); table.insert(nullKeyed); @@ -168,7 +179,7 @@ void hashCollisionsResolveByEquality() { // Force two distinct keys with the same hashCode -- the chain must still distinguish them // via matches(). Hashtable.D1 table = - Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); + Hashtable.D1.createBounded(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKeyEntry e1 = new CollidingKeyEntry(k1, 100); @@ -183,7 +194,7 @@ void hashCollisionsResolveByEquality() { @Test void hashCollisionsThenRemoveLeavesOtherIntact() { Hashtable.D1 table = - Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); + Hashtable.D1.createBounded(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -199,7 +210,8 @@ void hashCollisionsThenRemoveLeavesOtherIntact() { @Test void getOrCreateOnMissBuildsEntryViaCreator() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); int[] createCount = {0}; StringIntEntry created = table.tryGetOrCreateOrNull( @@ -218,7 +230,8 @@ void getOrCreateOnMissBuildsEntryViaCreator() { @Test void getOrCreateOnHitSkipsCreator() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); StringIntEntry seeded = new StringIntEntry("foo", 1); table.insert(seeded); int[] createCount = {0}; @@ -236,7 +249,8 @@ void getOrCreateOnHitSkipsCreator() { @Test void getOrCreateNullKeyIsPermitted() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); StringIntEntry created = table.tryGetOrCreateOrNull(null, k -> new StringIntEntry(k, 7)); assertNotNull(created); assertNull(created.key); @@ -247,7 +261,8 @@ void getOrCreateNullKeyIsPermitted() { @Test void getOrCreateAsMaybeOnMissBuildsEntryViaCreator() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + 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); @@ -256,7 +271,8 @@ void getOrCreateAsMaybeOnMissBuildsEntryViaCreator() { @Test void getOrCreateAsMaybeReturnsAbsentOnceAtCapacityButStillReturnsHits() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 2); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); @@ -269,7 +285,8 @@ void getOrCreateAsMaybeReturnsAbsentOnceAtCapacityButStillReturnsHits() { @Test void getOrCreateAsMaybeUpdateAppliesOnlyWhenPresent() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 1); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 1); table.insert(new StringIntEntry("a", 1)); ObjLongConsumer add = (e, n) -> e.value += n; @@ -282,7 +299,8 @@ void getOrCreateAsMaybeUpdateAppliesOnlyWhenPresent() { @Test void insertReturnsFalseOnceAtCapacity() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); + 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))); @@ -292,7 +310,8 @@ void insertReturnsFalseOnceAtCapacity() { @Test void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 2); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); @@ -305,7 +324,8 @@ void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { @Test void tryInsertOrReplaceStillReplacesAtCapacityButRefusesFreshInsert() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 2); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); @@ -324,7 +344,8 @@ void tryInsertOrReplaceStillReplacesAtCapacityButRefusesFreshInsert() { @Test void isFullReflectsCapacity() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 2); assertFalse(table.isFull()); table.insert(new StringIntEntry("a", 1)); assertFalse(table.isFull()); @@ -336,7 +357,8 @@ void isFullReflectsCapacity() { @Test void drainVisitsEveryEntryThenEmptiesTable() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + 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<>(); @@ -358,7 +380,8 @@ void drainVisitsEveryEntryThenEmptiesTable() { @Test void drainWithContextPassesContextToSink() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + 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<>(); @@ -371,7 +394,8 @@ void drainWithContextPassesContextToSink() { @Test void drainOnEmptyTableDoesNothing() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + 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()); @@ -380,7 +404,8 @@ void drainOnEmptyTableDoesNothing() { @Test void tryGetOrUpdateCreatesThenAppliesUpdater() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), e -> e.value += 5)); assertEquals(1, table.size()); assertEquals(5, table.get("a").value); @@ -388,7 +413,8 @@ void tryGetOrUpdateCreatesThenAppliesUpdater() { @Test void tryGetOrUpdateUpdatesExistingEntryInPlace() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 10)); StringIntEntry existing = table.get("a"); assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), e -> e.value += 5)); @@ -399,7 +425,8 @@ void tryGetOrUpdateUpdatesExistingEntryInPlace() { @Test void tryGetOrUpdateReturnsFalseAtCapacityWithoutUpdating() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 2); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); boolean[] updaterRan = {false}; @@ -417,7 +444,8 @@ void tryGetOrUpdateReturnsFalseAtCapacityWithoutUpdating() { @Test void tryGetOrUpdateAtCapacityStillUpdatesAnExistingKey() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 2); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), e -> e.value += 7)); @@ -426,7 +454,8 @@ void tryGetOrUpdateAtCapacityStillUpdatesAnExistingKey() { @Test void tryGetOrUpdateWithContextPassesContextToUpdater() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); // Boxed on purpose: an int literal would bind to the primitive-long overload instead. assertTrue( table.tryGetOrUpdate( @@ -440,7 +469,8 @@ void tryGetOrUpdateWithContextPassesContextToUpdater() { @Test void tryGetOrUpdateWithContextReturnsFalseAtCapacity() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 1); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 1); table.insert(new StringIntEntry("a", 1)); assertFalse( table.tryGetOrUpdate( @@ -450,7 +480,8 @@ void tryGetOrUpdateWithContextReturnsFalseAtCapacity() { @Test void tryGetOrUpdateWithLongContextCreatesThenAccumulates() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 8); assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 4L, ADD_LONG)); assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 6L, ADD_LONG)); assertEquals(1, table.size()); @@ -459,7 +490,8 @@ void tryGetOrUpdateWithLongContextCreatesThenAccumulates() { @Test void tryGetOrUpdateWithLongContextReturnsFalseAtCapacityWithoutUpdating() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 1); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 1); table.insert(new StringIntEntry("a", 1)); assertFalse(table.tryGetOrUpdate("b", k -> new StringIntEntry(k, 0), 4L, ADD_LONG)); assertEquals(1, table.size()); @@ -468,7 +500,8 @@ void tryGetOrUpdateWithLongContextReturnsFalseAtCapacityWithoutUpdating() { @Test void tryGetOrUpdateWithLongContextAtCapacityStillUpdatesAnExistingKey() { - Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 1); + Hashtable.D1 table = + Hashtable.D1.createBounded(StringIntEntry.class, 1); table.insert(new StringIntEntry("a", 1)); assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 4L, ADD_LONG)); assertEquals(5, table.get("a").value); 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 a7378f55904..191a20a5ab3 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java @@ -15,7 +15,7 @@ class HashtableD2Test { @Test void pairKeysParticipateInIdentity() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 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 +31,7 @@ void pairKeysParticipateInIdentity() { @Test void removePairUnlinks() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 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); @@ -44,7 +44,7 @@ void removePairUnlinks() { @Test void tryInsertOrReplaceMatchesOnBothKeys() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); PairEntry first = new PairEntry("k", 7, 1); assertTrue(table.tryInsertOrReplace(first)); PairEntry second = new PairEntry("k", 7, 2); @@ -58,7 +58,7 @@ void tryInsertOrReplaceMatchesOnBothKeys() { @Test void forEachVisitsBothPairs() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 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<>(); @@ -70,7 +70,7 @@ void forEachVisitsBothPairs() { @Test void forEachWithContextPassesContextToConsumer() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 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<>(); @@ -82,7 +82,7 @@ void forEachWithContextPassesContextToConsumer() { @Test void getOrCreateOnMissBuildsEntryViaCreator() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); int[] createCount = {0}; PairEntry created = table.tryGetOrCreateOrNull( @@ -103,7 +103,7 @@ void getOrCreateOnMissBuildsEntryViaCreator() { @Test void getOrCreateOnHitSkipsCreator() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); PairEntry seeded = new PairEntry("a", 1, 100); table.insert(seeded); int[] createCount = {0}; @@ -161,7 +161,7 @@ void entryHashDiffersForDifferentKeys() { @Test void removeReturnsNullForMissingKey() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 100)); assertNull(table.remove("a", 2)); @@ -171,7 +171,7 @@ void removeReturnsNullForMissingKey() { @Test void clearEmptiesTable() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 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()); @@ -185,7 +185,7 @@ void clearEmptiesTable() { @Test void insertReturnsFalseOnceAtCapacity() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); + 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))); @@ -195,7 +195,7 @@ void insertReturnsFalseOnceAtCapacity() { @Test void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 2); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); @@ -208,7 +208,7 @@ void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { @Test void getOrCreateAsMaybeReturnsAbsentOnceAtCapacityButStillReturnsHits() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 2); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); @@ -221,7 +221,7 @@ void getOrCreateAsMaybeReturnsAbsentOnceAtCapacityButStillReturnsHits() { @Test void tryInsertOrReplaceStillReplacesAtCapacityButRefusesFreshInsert() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 2); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); @@ -240,7 +240,7 @@ void tryInsertOrReplaceStillReplacesAtCapacityButRefusesFreshInsert() { @Test void isFullReflectsCapacity() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 2); assertFalse(table.isFull()); table.insert(new PairEntry("a", 1, 100)); assertFalse(table.isFull()); @@ -252,7 +252,7 @@ void isFullReflectsCapacity() { @Test void drainVisitsEveryEntryThenEmptiesTable() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 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 drained = new HashSet<>(); @@ -269,7 +269,7 @@ void drainVisitsEveryEntryThenEmptiesTable() { @Test void drainWithContextPassesContextToSink() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 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 drained = new HashSet<>(); @@ -282,7 +282,7 @@ void drainWithContextPassesContextToSink() { @Test void drainOnEmptyTableDoesNothing() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + 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()); @@ -291,7 +291,7 @@ void drainOnEmptyTableDoesNothing() { @Test void tryGetOrUpdateCreatesThenAppliesUpdater() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); assertTrue( table.tryGetOrUpdate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 0), e -> e.value += 5)); assertEquals(1, table.size()); @@ -300,7 +300,7 @@ void tryGetOrUpdateCreatesThenAppliesUpdater() { @Test void tryGetOrUpdateUpdatesExistingEntryInPlace() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 10)); PairEntry existing = table.get("a", 1); assertTrue( @@ -312,7 +312,7 @@ void tryGetOrUpdateUpdatesExistingEntryInPlace() { @Test void tryGetOrUpdateReturnsFalseAtCapacityWithoutUpdating() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 2); table.insert(new PairEntry("a", 1, 1)); table.insert(new PairEntry("b", 2, 2)); boolean[] updaterRan = {false}; @@ -331,7 +331,7 @@ void tryGetOrUpdateReturnsFalseAtCapacityWithoutUpdating() { @Test void tryGetOrUpdateWithContextPassesContextToUpdater() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); assertTrue( table.tryGetOrUpdate( "a", 1, (k1, k2) -> new PairEntry(k1, k2, 0), 4, (n, e) -> e.value += n)); @@ -344,7 +344,7 @@ void tryGetOrUpdateWithContextPassesContextToUpdater() { @Test void tryGetOrUpdateWithContextReturnsFalseAtCapacity() { - Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 1); + Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 1); table.insert(new PairEntry("a", 1, 1)); assertFalse( table.tryGetOrUpdate( From 790079133b3bbe264e0d85cbfd953d0c64b2b611 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 15:09:55 -0400 Subject: [PATCH 39/41] Port removeIf to Hashtable Adds D1.removeIf/D2.removeIf plus the static building-block overloads, matching ConcurrentHashtable's removeIf shape. Delegates to the existing SizeManager.evictAll full-table sweep rather than reimplementing traversal. --- .../java/datadog/trace/util/Hashtable.java | 33 +++++++++++++++++++ .../datadog/trace/util/HashtableD1Test.java | 28 ++++++++++++++++ .../datadog/trace/util/HashtableD2Test.java | 30 +++++++++++++++++ 3 files changed, 91 insertions(+) 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 d596a9da3d7..2306e4c72e4 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -250,6 +250,13 @@ public TEntry remove(@Nullable K key) { return null; } + /** + * 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); + } + /** * 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 @@ -612,6 +619,13 @@ public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { return null; } + /** + * 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); + } + /** 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); @@ -1114,6 +1128,25 @@ public static int evictAll( 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); 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 45b994b7688..d963c84e08a 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -94,6 +94,34 @@ void removeNonexistentReturnsNullAndDoesNotChangeSize() { assertEquals(1, table.size()); } + @Test + 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 tryInsertOrReplaceInsertsThenReplacesWithoutGrowing() { Hashtable.D1 table = 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 191a20a5ab3..8905aa6961f 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java @@ -42,6 +42,36 @@ void removePairUnlinks() { assertSame(ac, table.get("a", 2)); } + @Test + 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 tryInsertOrReplaceMatchesOnBothKeys() { Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); From 97a404dd102f2f5cde0de8db0ce2625c891b5b12 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 15:17:17 -0400 Subject: [PATCH 40/41] Port tryGetOrCreateOrEvict to Hashtable Adds D1/D2 tryGetOrCreateOrEvict and tryGetOrCreateOrEvictOrNull, matching ConcurrentHashtable's shape and its eviction-before-creator ordering (creator may throw, so the freed slot is not reserved until after it succeeds). --- .../java/datadog/trace/util/Hashtable.java | 95 +++++++++++++++++++ .../datadog/trace/util/HashtableD1Test.java | 88 +++++++++++++++++ .../datadog/trace/util/HashtableD2Test.java | 86 +++++++++++++++++ 3 files changed, 269 insertions(+) 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 2306e4c72e4..badc7dd5d53 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -362,6 +362,52 @@ public TEntry tryGetOrCreateOrNull( return newEntry; } + /** + * {@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)); + } + + /** + * 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 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 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(key); + insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); + this.sizeManager.increment(); + return newEntry; + } + /** * {@link #tryGetOrCreateOrNull} followed by {@code updater}, returning whether the update * happened. @@ -698,6 +744,55 @@ public TEntry tryGetOrCreateOrNull( return newEntry; } + /** + * 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)); + } + + /** + * 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; + } + /** * Two-key analogue of {@link D1#tryGetOrUpdate(Object, Function, Consumer)}: applies {@code * updater} to the entry for {@code (key1, key2)}, creating one if absent, and returns whether 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 d963c84e08a..a9e5b563842 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -8,6 +8,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.HashMap; @@ -122,6 +123,93 @@ void removeIfReturnsFalseAndLeavesTableUntouchedWhenNothingMatches() { 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 = 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 8905aa6961f..3ea7926eb58 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; @@ -72,6 +73,91 @@ void removeIfReturnsFalseAndLeavesTableUntouchedWhenNothingMatches() { 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); From 18ef04dc8433eb7be392ff999a66affebf2f3447 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 15:41:31 -0400 Subject: [PATCH 41/41] Remove Hashtable.tryGetOrUpdate family (D1/D2) These fused create+update overloads predate Maybe's update() methods proving out as allocation-free under escape analysis, and were an experiment from before that. Maybe.getOrNull()/update() now cover the same shape generically with no production callers of the removed methods. --- .../java/datadog/trace/util/Hashtable.java | 118 ------------------ .../main/java/datadog/trace/util/Maybe.java | 4 +- .../datadog/trace/util/HashtableD1Test.java | 107 ---------------- .../datadog/trace/util/HashtableD2Test.java | 63 ---------- 4 files changed, 2 insertions(+), 290 deletions(-) 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 badc7dd5d53..d762c1ae305 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -9,7 +9,6 @@ import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; -import java.util.function.ObjLongConsumer; import java.util.function.Predicate; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -408,83 +407,6 @@ public TEntry tryGetOrCreateOrEvictOrNull( return newEntry; } - /** - * {@link #tryGetOrCreateOrNull} followed by {@code updater}, returning whether the update - * happened. - * - *

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

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

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

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

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

    The generic context overload would box {@code n} on every call; this one does not. Note - * the argument order is {@code (entry, value)} -- {@link ObjLongConsumer}'s, not the {@code - * (context, entry)} of the {@link BiConsumer} overload. - */ - public boolean tryGetOrUpdate( - @Nullable K key, - @Nonnull Function creator, - long context, - @Nonnull ObjLongConsumer updater) { - TEntry entry = tryGetOrCreateOrNull(key, creator); - if (entry == null) { - return false; - } - updater.accept(entry, context); - return true; - } - public void forEach(@Nonnull Consumer consumer) { Hashtable.forEach(this.buckets, consumer); } @@ -793,46 +715,6 @@ public TEntry tryGetOrCreateOrEvictOrNull( return newEntry; } - /** - * Two-key analogue of {@link D1#tryGetOrUpdate(Object, Function, Consumer)}: applies {@code - * updater} to the entry for {@code (key1, key2)}, creating one if absent, and returns whether - * the update happened. Returns {@code false} without updating when the pair is absent and the - * table is at capacity. See the single-key form for why fusing the update is preferred over - * {@code tryGetOrCreateOrNull(...)} followed by a dereference. - */ - public boolean tryGetOrUpdate( - @Nullable K1 key1, - @Nullable K2 key2, - @Nonnull BiFunction creator, - @Nonnull Consumer updater) { - TEntry entry = tryGetOrCreateOrNull(key1, key2, creator); - if (entry == null) { - return false; - } - updater.accept(entry); - return true; - } - - /** - * Context-passing {@link #tryGetOrUpdate(Object, Object, BiFunction, Consumer)}, for updates - * that need a value the entry doesn't carry. Pass a non-capturing {@link BiConsumer} (typically - * a {@code static final}) plus its side-band state as {@code context} to avoid allocating a - * capturing lambda per call. - */ - public boolean tryGetOrUpdate( - @Nullable K1 key1, - @Nullable K2 key2, - @Nonnull BiFunction creator, - C context, - @Nonnull BiConsumer updater) { - TEntry entry = tryGetOrCreateOrNull(key1, key2, creator); - if (entry == null) { - return false; - } - updater.accept(context, entry); - return true; - } - public void forEach(@Nonnull Consumer consumer) { Hashtable.forEach(this.buckets, consumer); } 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 fc941cd872e..b4c3c78ab12 100644 --- a/internal-api/src/main/java/datadog/trace/util/Maybe.java +++ b/internal-api/src/main/java/datadog/trace/util/Maybe.java @@ -110,8 +110,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 of {@code update}. A second one * (e.g. {@code int}) was tried and reverted: with two primitive overloads, {@code update(1, 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 a9e5b563842..6bfc168f17e 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -517,111 +517,4 @@ void drainOnEmptyTableDoesNothing() { assertEquals(0, drained.size()); assertEquals(0, table.size()); } - - @Test - void tryGetOrUpdateCreatesThenAppliesUpdater() { - Hashtable.D1 table = - Hashtable.D1.createBounded(StringIntEntry.class, 8); - assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), e -> e.value += 5)); - assertEquals(1, table.size()); - assertEquals(5, table.get("a").value); - } - - @Test - void tryGetOrUpdateUpdatesExistingEntryInPlace() { - Hashtable.D1 table = - Hashtable.D1.createBounded(StringIntEntry.class, 8); - table.insert(new StringIntEntry("a", 10)); - StringIntEntry existing = table.get("a"); - assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), e -> e.value += 5)); - assertEquals(1, table.size()); - assertEquals(15, existing.value); - assertSame(existing, table.get("a")); - } - - @Test - void tryGetOrUpdateReturnsFalseAtCapacityWithoutUpdating() { - Hashtable.D1 table = - Hashtable.D1.createBounded(StringIntEntry.class, 2); - table.insert(new StringIntEntry("a", 1)); - table.insert(new StringIntEntry("b", 2)); - boolean[] updaterRan = {false}; - assertFalse( - table.tryGetOrUpdate( - "c", - k -> new StringIntEntry(k, 0), - e -> { - updaterRan[0] = true; - })); - assertFalse(updaterRan[0], "updater must not run when the create is refused"); - assertEquals(2, table.size()); - assertNull(table.get("c")); - } - - @Test - void tryGetOrUpdateAtCapacityStillUpdatesAnExistingKey() { - Hashtable.D1 table = - Hashtable.D1.createBounded(StringIntEntry.class, 2); - table.insert(new StringIntEntry("a", 1)); - table.insert(new StringIntEntry("b", 2)); - assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), e -> e.value += 7)); - assertEquals(8, table.get("a").value); - } - - @Test - void tryGetOrUpdateWithContextPassesContextToUpdater() { - Hashtable.D1 table = - Hashtable.D1.createBounded(StringIntEntry.class, 8); - // Boxed on purpose: an int literal would bind to the primitive-long overload instead. - assertTrue( - table.tryGetOrUpdate( - "a", k -> new StringIntEntry(k, 0), Integer.valueOf(4), (n, e) -> e.value += n)); - assertTrue( - table.tryGetOrUpdate( - "a", k -> new StringIntEntry(k, 0), Integer.valueOf(6), (n, e) -> e.value += n)); - assertEquals(1, table.size()); - assertEquals(10, table.get("a").value); - } - - @Test - void tryGetOrUpdateWithContextReturnsFalseAtCapacity() { - Hashtable.D1 table = - Hashtable.D1.createBounded(StringIntEntry.class, 1); - table.insert(new StringIntEntry("a", 1)); - assertFalse( - table.tryGetOrUpdate( - "b", k -> new StringIntEntry(k, 0), Integer.valueOf(4), (n, e) -> e.value += n)); - assertEquals(1, table.size()); - } - - @Test - void tryGetOrUpdateWithLongContextCreatesThenAccumulates() { - Hashtable.D1 table = - Hashtable.D1.createBounded(StringIntEntry.class, 8); - assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 4L, ADD_LONG)); - assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 6L, ADD_LONG)); - assertEquals(1, table.size()); - assertEquals(10, table.get("a").value); - } - - @Test - void tryGetOrUpdateWithLongContextReturnsFalseAtCapacityWithoutUpdating() { - Hashtable.D1 table = - Hashtable.D1.createBounded(StringIntEntry.class, 1); - table.insert(new StringIntEntry("a", 1)); - assertFalse(table.tryGetOrUpdate("b", k -> new StringIntEntry(k, 0), 4L, ADD_LONG)); - assertEquals(1, table.size()); - assertEquals(1, table.get("a").value); - } - - @Test - void tryGetOrUpdateWithLongContextAtCapacityStillUpdatesAnExistingKey() { - Hashtable.D1 table = - Hashtable.D1.createBounded(StringIntEntry.class, 1); - table.insert(new StringIntEntry("a", 1)); - assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 4L, ADD_LONG)); - assertEquals(5, table.get("a").value); - } - - private static final ObjLongConsumer ADD_LONG = (e, n) -> e.value += (int) n; } diff --git a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java index 3ea7926eb58..2766cffe015 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java @@ -405,69 +405,6 @@ void drainOnEmptyTableDoesNothing() { assertEquals(0, table.size()); } - @Test - void tryGetOrUpdateCreatesThenAppliesUpdater() { - Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); - assertTrue( - table.tryGetOrUpdate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 0), e -> e.value += 5)); - assertEquals(1, table.size()); - assertEquals(5, table.get("a", 1).value); - } - - @Test - void tryGetOrUpdateUpdatesExistingEntryInPlace() { - Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); - table.insert(new PairEntry("a", 1, 10)); - PairEntry existing = table.get("a", 1); - assertTrue( - table.tryGetOrUpdate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 0), e -> e.value += 5)); - assertEquals(1, table.size()); - assertEquals(15, existing.value); - assertSame(existing, table.get("a", 1)); - } - - @Test - void tryGetOrUpdateReturnsFalseAtCapacityWithoutUpdating() { - Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 2); - table.insert(new PairEntry("a", 1, 1)); - table.insert(new PairEntry("b", 2, 2)); - boolean[] updaterRan = {false}; - assertFalse( - table.tryGetOrUpdate( - "c", - 3, - (k1, k2) -> new PairEntry(k1, k2, 0), - e -> { - updaterRan[0] = true; - })); - assertFalse(updaterRan[0], "updater must not run when the create is refused"); - assertEquals(2, table.size()); - assertNull(table.get("c", 3)); - } - - @Test - void tryGetOrUpdateWithContextPassesContextToUpdater() { - Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 8); - assertTrue( - table.tryGetOrUpdate( - "a", 1, (k1, k2) -> new PairEntry(k1, k2, 0), 4, (n, e) -> e.value += n)); - assertTrue( - table.tryGetOrUpdate( - "a", 1, (k1, k2) -> new PairEntry(k1, k2, 0), 6, (n, e) -> e.value += n)); - assertEquals(1, table.size()); - assertEquals(10, table.get("a", 1).value); - } - - @Test - void tryGetOrUpdateWithContextReturnsFalseAtCapacity() { - Hashtable.D2 table = Hashtable.D2.createBounded(PairEntry.class, 1); - table.insert(new PairEntry("a", 1, 1)); - assertFalse( - table.tryGetOrUpdate( - "b", 2, (k1, k2) -> new PairEntry(k1, k2, 0), 4, (n, e) -> e.value += n)); - assertEquals(1, table.size()); - } - private static final class PairEntry extends Hashtable.D2.Entry { int value;