diff --git a/internal-api/src/jmh/java/datadog/trace/api/telemetry/LogCollectorBenchmark.java b/internal-api/src/jmh/java/datadog/trace/api/telemetry/LogCollectorBenchmark.java index d7c836b8621..0899c8fecf6 100644 --- a/internal-api/src/jmh/java/datadog/trace/api/telemetry/LogCollectorBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/api/telemetry/LogCollectorBenchmark.java @@ -2,7 +2,11 @@ import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.Threads; import org.openjdk.jmh.annotations.Warmup; @@ -11,15 +15,25 @@ @Measurement(iterations = 5) @Threads(8) public class LogCollectorBenchmark { + @State(Scope.Benchmark) + public static class CollectorState { + final LogCollector collector = new LogCollector(4); + + @Setup(Level.Trial) + public void setup() { + collector.addLogMessage("error", "ugh!", null); + } + } + @Benchmark - public void noException_before() { - LogCollector.get().addLogMessage("error", "ugh!", null); + public void duplicateWithoutException(CollectorState state) { + state.collector.addLogMessage("error", "ugh!", null); } static final Object NULL = null; @Benchmark - public void nullPointerException() { + public void nullPointerException(CollectorState state) { // Represents the fast throw case where the JVM switches to using // a single Exception instance to handle a hot throw location // of NullPointerException, ArrayIndexOutOfBoundsException, etc. @@ -27,18 +41,18 @@ public void nullPointerException() { try { NULL.hashCode(); } catch (Throwable t) { - LogCollector.get().addLogMessage("error", "npe", t); + state.collector.addLogMessage("error", "npe", t); } } @Benchmark - public void unsupportedOperationException() { + public void unsupportedOperationException(CollectorState state) { // Represents the common case where stack trace is preserved // despite hot throw try { unsupportedOperation(); } catch (Throwable t) { - LogCollector.get().addLogMessage("error", "unsupported", t); + state.collector.addLogMessage("error", "unsupported", t); } } diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java deleted file mode 100644 index 63ccb734e9c..00000000000 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java +++ /dev/null @@ -1,293 +0,0 @@ -package datadog.trace.util; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentSkipListMap; -import java.util.function.Supplier; -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Measurement; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.State; -import org.openjdk.jmh.annotations.Threads; -import org.openjdk.jmh.annotations.Warmup; - -/** - * - * - *

- * - *

In most situations in dd-java-agent, ConcurrentMaps are not necessarily needed and incur - * additional overhead. ConcurrentMaps make sense when concurrent writers are likely. - * - *

If a Map can be created atomically in one thread and then stored into a volatile, that is the - * preferred solution. For example, requesting an update from agent / API and then exposing to the - * rest of the tracer via a global. - * - *

If a Map needs to be written in a thread-safe manner, but is primarily accessed from one - * thread at a time, then a synchronized HashMap is usually the best option. - * MacBook M1 with 1 thread (Java 21) - * - * Benchmark Mode Cnt Score Error Units - * ThreadSafeMapBenchmark.create_concHashMap thrpt 6 8081979.153 ± 261559.222 ops/s - * ThreadSafeMapBenchmark.create_concSkipListMap thrpt 6 2998832.124 ± 103708.038 ops/s - * ThreadSafeMapBenchmark.create_hashMap thrpt 6 24938311.610 ± 673725.902 ops/s - * ThreadSafeMapBenchmark.create_hashMap_synchronized thrpt 6 7971740.607 ± 121986.296 ops/s - * - * ThreadSafeMapBenchmark.get_concHashMap thrpt 6 173942565.340 ± 12003493.448 ops/s - * ThreadSafeMapBenchmark.get_concSkipListMap thrpt 6 79230298.061 ± 13007895.765 ops/s - * ThreadSafeMapBenchmark.get_hashMap_synchronized thrpt 6 98056657.832 ± 3413815.061 ops/s - * ThreadSafeMapBenchmark.get_hashMap_volatile thrpt 6 210511753.596 ± 5017502.317 ops/s - * - * MacBook M1 with 8 threads (Java 21) - * - * Benchmark Mode Cnt Score Error Units - * ThreadSafeMapBenchmark.create_concHashMap thrpt 6 58015351.219 ± 6201384.867 ops/s - * ThreadSafeMapBenchmark.create_concSkipListMap thrpt 6 19296105.790 ± 4516587.751 ops/s - * ThreadSafeMapBenchmark.create_hashMap thrpt 6 147917381.815 ± 22901897.589 ops/s - * ThreadSafeMapBenchmark.create_hashMap_synchronized thrpt 6 56466354.962 ± 13202034.783 ops/s - * - * ThreadSafeMapBenchmark.get_concHashMap thrpt 6 849986442.797 ± 14499355.893 ops/s - * ThreadSafeMapBenchmark.get_concSkipListMap thrpt 6 26828246.629 ± 2772377.532 ops/s - * ThreadSafeMapBenchmark.get_hashMap_synchronized thrpt 6 20123419.604 ± 4858466.787 ops/s - * ThreadSafeMapBenchmark.get_hashMap_volatile thrpt 6 286024211.995 ± 114449056.603 ops/s - * - */ -@Fork(2) -@Warmup(iterations = 2) -@Measurement(iterations = 3) -@Threads(8) -@State(Scope.Thread) -public class ThreadSafeMapBenchmark { - static final String[] INSERTION_KEYS = { - "foo", "bar", "baz", "quux", "foobar", "foobaz", "key0", "key1", "key2", "key3" - }; - - static final String[] EQUAL_KEYS = - init( - () -> { - String[] keys = new String[INSERTION_KEYS.length]; - for (int i = 0; i < INSERTION_KEYS.length; ++i) { - keys[i] = new String(INSERTION_KEYS[i]); - } - return keys; - }); - - static T init(Supplier supplier) { - return supplier.get(); - } - - // Per-thread (@State(Scope.Thread)) so cycling the lookup key doesn't contend a shared counter. - // The maps below stay static/shared (the point — concurrent reads of one map); only the index is - // per-thread. A shared counter's cache-line ping-pong would otherwise floor the fastest reads - // (e.g. FlatHashtable's lock-free probe), hiding exactly the differences this benchmark compares. - int lookupIndex = 0; - - String nextLookupKey() { - return nextLookupKey(EQUAL_KEYS); - } - - String nextLookupKey(String[] keys) { - int localIndex = ++lookupIndex; - if (localIndex >= keys.length) { - lookupIndex = localIndex = 0; - } - return keys[localIndex]; - } - - static void fill(Map map) { - for (int i = 0; i < INSERTION_KEYS.length; ++i) { - map.put(INSERTION_KEYS[i], i); - } - } - - // FlatHashtable's contribution here is the lock-free concurrent read: get() is a plain array - // probe - // with no lock and no volatile — safe under concurrency because the table is published once (a - // final static field) and each entry's identity fields are final. (Fixture mirrors the one in - // SingleThreadedMapBenchmark; the benchmarks are self-contained.) - static final class IntEntry { - final String key; - final int value; - - IntEntry(String key, int value) { - this.key = key; - this.value = value; - } - } - - static final class IntEntryKeyStrategy extends FlatHashtable.EntryStrategy { - static final IntEntryKeyStrategy INSTANCE = new IntEntryKeyStrategy(); - - private IntEntryKeyStrategy() {} - - @Override - public boolean matches(IntEntry entry, String key) { - return key.equals(entry.key); - } - - @Override - public long hashOf(IntEntry entry) { - return entry.key.hashCode(); // consistent with the default hashKey - } - } - - // --- CHA-defeat decoys --------------------------------------------------------------------- - // These are never used to build a table; they exist only to be *loaded* (see CHA_DEFEAT), so - // MatchingStrategy.matches and .hashKey each have >=2 concrete implementors. That denies C2 the - // single-implementor CHA devirtualization of matchStrat.hashKey/matches inside get(). If the - // strategy calls still inline afterward, the win is structural (the constant INSTANCE's exact - // type propagated through the inlined get), not a CHA bet that would deopt on a second subclass. - - // Second matches impl -> MatchingStrategy.matches is polymorphic. - static final class DecoyMatchStrategy extends FlatHashtable.EntryStrategy { - static final DecoyMatchStrategy INSTANCE = new DecoyMatchStrategy(); - - private DecoyMatchStrategy() {} - - @Override - public boolean matches(IntEntry entry, String key) { - return key == entry.key; // deliberately different body from IntEntryKeyStrategy - } - - @Override - public long hashOf(IntEntry entry) { - return entry.key.hashCode(); - } - } - - // Overrides hashKey -> MatchingStrategy.hashKey is polymorphic too (default + this override). - static final class DecoyHashKeyStrategy extends FlatHashtable.EntryStrategy { - static final DecoyHashKeyStrategy INSTANCE = new DecoyHashKeyStrategy(); - - private DecoyHashKeyStrategy() {} - - @Override - public long hashKey(String key) { - return key.length(); - } - - @Override - public boolean matches(IntEntry entry, String key) { - return key.equals(entry.key); - } - - @Override - public long hashOf(IntEntry entry) { - return entry.key.length(); - } - } - - // Referenced only so these three concrete implementors load at benchmark class-init, before the - // hot method compiles — see the CHA-defeat note above. - @SuppressWarnings("unused") - static final Object[] CHA_DEFEAT = { - IntEntryKeyStrategy.INSTANCE, DecoyMatchStrategy.INSTANCE, DecoyHashKeyStrategy.INSTANCE - }; - - static IntEntry[] _create_flat() { - // Sized to the key count (FlatHashtable is fixed-capacity, no resize): load factor <= 0.5. - IntEntry[] table = FlatHashtable.create(IntEntry.class, INSERTION_KEYS.length); - for (int i = 0; i < INSERTION_KEYS.length; ++i) { - FlatHashtable.insert(table, new IntEntry(INSERTION_KEYS[i], i), IntEntryKeyStrategy.INSTANCE); - } - return table; - } - - static final HashMap _create_hashMap() { - HashMap map = new HashMap<>(); - fill(map); - return map; - } - - @Benchmark - public Map create_hashMap() { - return _create_hashMap(); - } - - static volatile HashMap VOLATILE_HASH_MAP = _create_hashMap(); - - @Benchmark - public Integer get_hashMap_volatile() { - Map map = VOLATILE_HASH_MAP; - return map.get(nextLookupKey()); - } - - static final Map _create_hashMap_synchronized() { - Map map = Collections.synchronizedMap(new HashMap<>()); - fill(map); - return map; - } - - @Benchmark - public Map create_hashMap_synchronized() { - return _create_hashMap_synchronized(); - } - - static final Map SYNC_HASH_MAP = _create_hashMap_synchronized(); - - @Benchmark - public Integer get_hashMap_synchronized() { - return SYNC_HASH_MAP.get(nextLookupKey()); - } - - static ConcurrentHashMap _create_concHashMap() { - ConcurrentHashMap map = new ConcurrentHashMap<>(); - fill(map); - return map; - } - - @Benchmark - public ConcurrentHashMap create_concHashMap() { - return _create_concHashMap(); - } - - static final ConcurrentHashMap CONC_HASH_MAP = _create_concHashMap(); - - @Benchmark - public Integer get_concHashMap() { - return CONC_HASH_MAP.get(nextLookupKey()); - } - - static ConcurrentSkipListMap _create_concSkipListMap() { - ConcurrentSkipListMap map = new ConcurrentSkipListMap<>(); - fill(map); - return map; - } - - @Benchmark - public ConcurrentSkipListMap create_concSkipListMap() { - return _create_concSkipListMap(); - } - - static final ConcurrentSkipListMap CONC_SKIP_LIST_MAP = - _create_concSkipListMap(); - - @Benchmark - public Integer get_concSkipListMap() { - return CONC_SKIP_LIST_MAP.get(nextLookupKey()); - } - - @Benchmark - public IntEntry[] create_flatHashtable() { - return _create_flat(); - } - - static final IntEntry[] FLAT_TABLE = _create_flat(); - - @Benchmark - public IntEntry get_flatHashtable() { - // Lock-free concurrent read of the shared, once-published table. - return FlatHashtable.get(FLAT_TABLE, nextLookupKey(), IntEntryKeyStrategy.INSTANCE); - } -} diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java new file mode 100644 index 00000000000..bc8bc07b9f5 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java @@ -0,0 +1,140 @@ +package datadog.trace.util; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicLongFieldUpdater; +import java.util.concurrent.atomic.LongAdder; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Measures lookup followed by an atomic counter increment in a shared, pre-populated table. Models + * per-class or per-method hit counters in the tracer. + * + *

The {@link ConcurrentHashtable.D1} case embeds a {@code volatile long} in each entry. {@link + * AtomicLongFieldUpdater} updates that field atomically without allocating an {@link AtomicLong} + * per key. The map baselines store a separate {@link AtomicLong} or {@link LongAdder}; {@code + * LongAdder} spreads contention across internal cells at the cost of more memory and a more + * expensive read. + * + *

Lookups reuse the key instances installed during setup. {@code Objects.equals} therefore + * returns on its identity check without dispatching to {@code equals}, so this measures the + * interned-key pattern used by the tracer rather than distinct-but-equal keys. + * + *

Java 17 results ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys): + * + *

{@code
+ * Benchmark                          Score   Units
+ * increment_longAdder                   79   ops/us
+ * increment_atomicLong                  71   ops/us
+ * increment_concurrentHashtable         69   ops/us
+ * }
+ * + *

Key findings: + * + *

+ */ +@Fork(2) +@Warmup(iterations = 2) +@Measurement(iterations = 3) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(MICROSECONDS) +@Threads(8) +public class ThreadSafeMapCounterBenchmark { + + static final int N_KEYS = 64; + static final int CAPACITY = 128; + + static final String[] KEYS = new String[N_KEYS]; + + static { + for (int i = 0; i < N_KEYS; ++i) { + KEYS[i] = "key-" + i; + } + } + + static final class CounterEntry extends ConcurrentHashtable.D1.Entry { + private static final AtomicLongFieldUpdater COUNT = + AtomicLongFieldUpdater.newUpdater(CounterEntry.class, "count"); + + volatile long count; + + CounterEntry(String key) { + super(key); + } + + long increment() { + return COUNT.incrementAndGet(this); + } + } + + /** + * Shared state ({@link Scope#Benchmark}): one instance of each map across all threads, modelling + * a shared instrumentation counter table. + */ + @State(Scope.Benchmark) + public static class SharedState { + ConcurrentHashtable.D1 table; + ConcurrentHashMap atomicLongMap; + ConcurrentHashMap longAdderMap; + + @Setup(Level.Iteration) + public void setUp() { + table = ConcurrentHashtable.D1.createBounded(CounterEntry.class, CAPACITY); + atomicLongMap = new ConcurrentHashMap<>(CAPACITY); + longAdderMap = new ConcurrentHashMap<>(CAPACITY); + for (int i = 0; i < N_KEYS; ++i) { + table.tryGetOrCreateOrNull(KEYS[i], CounterEntry::new); + atomicLongMap.put(KEYS[i], new AtomicLong()); + longAdderMap.put(KEYS[i], new LongAdder()); + } + } + } + + /** Per-thread cursor so each thread cycles through keys independently. */ + @State(Scope.Thread) + public static class ThreadState { + int cursor; + + int next() { + int i = cursor; + cursor = (i + 1) & (N_KEYS - 1); + return i; + } + } + + @Benchmark + public long increment_concurrentHashtable(SharedState s, ThreadState t) { + return s.table.get(KEYS[t.next()]).increment(); + } + + @Benchmark + public long increment_atomicLong(SharedState s, ThreadState t) { + return s.atomicLongMap.get(KEYS[t.next()]).incrementAndGet(); + } + + @Benchmark + public void increment_longAdder(SharedState s, ThreadState t) { + s.longAdderMap.get(KEYS[t.next()]).increment(); + } +} diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java new file mode 100644 index 00000000000..8fd07544264 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java @@ -0,0 +1,187 @@ +package datadog.trace.util; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentSkipListMap; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Measures steady-state single-key lookups in a shared, pre-populated table. + * + *

Compares {@link ConcurrentHashtable.D1}, {@link ConcurrentHashMap}, {@link + * ConcurrentSkipListMap}, and a synchronized {@link HashMap}. The table is shared across all + * threads ({@link Scope#Benchmark}) and pre-populated before the measurement iteration — modelling + * the steady-state read-mostly pattern that the tracer uses (a per-class or per-method + * instrumentation cache consulted on every invocation). The {@code getOrCreate} methods exercise + * their hit paths because setup installs every key. + * + *

Lookups reuse the key instances installed during setup. {@code Objects.equals} therefore + * returns on identity before invoking {@code equals}; the benchmark does not include the cost of + * comparing distinct-but-equal keys ({@code ImmutableMapBenchmark} covers that path explicitly via + * its {@code _sameKey} vs default variants). See {@link ThreadSafeMapD2Benchmark} for composite + * keys. + * + *

Java 17 results ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys): + * + *

{@code
+ * Benchmark                             Score   Units
+ * get_concurrentHashtable               1583   ops/us
+ * get_concurrentHashMap                 1145   ops/us
+ * get_concurrentSkipListMap              170   ops/us
+ * get_synchronizedHashMap                 33   ops/us
+ *
+ * getOrCreate_concurrentHashtable       1450   ops/us
+ * getOrCreate_concurrentHashMap         1125   ops/us
+ * getOrCreate_synchronizedHashMap         31   ops/us
+ * }
+ * + *

Key findings: + * + *

    + *
  • {@code ConcurrentHashtable} is ~38% faster than {@code ConcurrentHashMap} on {@code get} + * (1583 vs 1145 ops/us); avoids the hash-to-segment translation CHM pays even on its fast + * path. + *
  • {@code ConcurrentSkipListMap} is ~9× slower than {@code ConcurrentHashMap} — tree traversal + * cost is high even under lock-free CAS. + *
  • Synchronized {@code HashMap} is ~47× slower than {@code ConcurrentHashtable}; the global + * lock serializes all 8 threads. + *
  • {@code getOrCreate} is near-identical to {@code get} because all keys are pre-populated — + * the lock branch is never taken during measurement. + *
+ */ +@Fork(2) +@Warmup(iterations = 2) +@Measurement(iterations = 3) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(MICROSECONDS) +@Threads(8) +public class ThreadSafeMapD1Benchmark { + + static final int N_KEYS = 64; + static final int CAPACITY = 128; + + static final String[] KEYS = new String[N_KEYS]; + + static { + for (int i = 0; i < N_KEYS; ++i) { + KEYS[i] = "key-" + i; + } + } + + static final class D1Entry extends ConcurrentHashtable.D1.Entry { + final long value; + + D1Entry(String key) { + super(key); + this.value = 1L; + } + } + + /** + * Shared state ({@link Scope#Benchmark}): one instance of each map across all threads, modelling + * a shared instrumentation cache. + */ + @State(Scope.Benchmark) + public static class SharedState { + ConcurrentHashtable.D1 table; + ConcurrentHashMap concurrentHashMap; + ConcurrentSkipListMap skipListMap; + Map synchronizedHashMap; + + @Setup(Level.Iteration) + public void setUp() { + table = ConcurrentHashtable.D1.createBounded(D1Entry.class, CAPACITY); + concurrentHashMap = new ConcurrentHashMap<>(CAPACITY); + skipListMap = new ConcurrentSkipListMap<>(); + synchronizedHashMap = Collections.synchronizedMap(new HashMap<>(CAPACITY)); + for (int i = 0; i < N_KEYS; ++i) { + table.tryGetOrCreateOrNull(KEYS[i], D1Entry::new); + concurrentHashMap.put(KEYS[i], (long) i); + skipListMap.put(KEYS[i], (long) i); + synchronizedHashMap.put(KEYS[i], (long) i); + } + } + } + + /** Per-thread cursor so each thread cycles through keys independently. */ + @State(Scope.Thread) + public static class ThreadState { + int cursor; + + int next() { + int i = cursor; + cursor = (i + 1) & (N_KEYS - 1); + return i; + } + } + + @Benchmark + public D1Entry get_concurrentHashtable(SharedState s, ThreadState t) { + return s.table.get(KEYS[t.next()]); + } + + @Benchmark + public Long get_concurrentHashMap(SharedState s, ThreadState t) { + return s.concurrentHashMap.get(KEYS[t.next()]); + } + + @Benchmark + public Long get_concurrentSkipListMap(SharedState s, ThreadState t) { + return s.skipListMap.get(KEYS[t.next()]); + } + + @Benchmark + public Long get_synchronizedHashMap(SharedState s, ThreadState t) { + return s.synchronizedHashMap.get(KEYS[t.next()]); + } + + @Benchmark + public D1Entry getOrCreate_concurrentHashtable(SharedState s, ThreadState t) { + return s.table.tryGetOrCreateOrNull(KEYS[t.next()], D1Entry::new); + } + + /** + * get-first pattern for CHM — the idiomatic equivalent of D1.getOrCreate on a mostly-populated + * table. + */ + @Benchmark + public Long getOrCreate_concurrentHashMap(SharedState s, ThreadState t) { + String key = KEYS[t.next()]; + Long existing = s.concurrentHashMap.get(key); + if (existing != null) { + return existing; + } + return s.concurrentHashMap.computeIfAbsent(key, k -> 0L); + } + + /** + * get-first pattern for synchronized HashMap. On hit: one lock acquire/release for get. On miss: + * a second synchronized block for the double-checked put. + */ + @Benchmark + public Long getOrCreate_synchronizedHashMap(SharedState s, ThreadState t) { + String key = KEYS[t.next()]; + Long existing = s.synchronizedHashMap.get(key); + if (existing != null) { + return existing; + } + synchronized (s.synchronizedHashMap) { + return s.synchronizedHashMap.computeIfAbsent(key, k -> 0L); + } + } +} diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java new file mode 100644 index 00000000000..aff30dd0a33 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java @@ -0,0 +1,336 @@ +package datadog.trace.util; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentSkipListMap; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Measures steady-state composite-key lookups in a shared, pre-populated table. + * + *

Compares {@link ConcurrentHashtable.D2}, a custom {@link ConcurrentHashtable.Entry} with a + * primitive {@code int} key part, {@link ConcurrentHashMap}, {@link ConcurrentSkipListMap}, and a + * synchronized {@link HashMap}. The table is shared across all threads ({@link Scope#Benchmark}) + * and pre-populated before the measurement iteration — modelling the steady-state read-mostly + * pattern that the tracer uses (a per-class or per-method instrumentation cache consulted on every + * invocation). + * + *

The map cases create a {@link Key2} for each lookup. HotSpot may remove that allocation only + * when inlining and escape analysis prove that the wrapper is not retained; a miss that inserts the + * key makes it escape. The concurrent hashtable passes key parts directly, and the custom entry + * also avoids boxing the {@code int}, so neither optimization depends on escape analysis. + * + *

Lookups reuse the key-part instances installed during setup, taking the identity fast path for + * their object comparisons. See {@link ThreadSafeMapD1Benchmark} for single-key lookups. + * + *

Java 17 results ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys): + * + *

{@code
+ * Benchmark                              Score   Units
+ * get_concurrentHashtable                1452   ops/us
+ * get_support                            1450   ops/us
+ * get_concurrentHashMap                   777   ops/us
+ * get_concurrentSkipListMap               146   ops/us
+ * get_synchronizedHashMap                  27   ops/us
+ *
+ * getOrCreate_support                    1379   ops/us
+ * getOrCreate_concurrentHashtable        1119   ops/us
+ * getOrCreate_concurrentHashMap           769   ops/us
+ * getOrCreate_concurrentSkipListMap       151   ops/us
+ * getOrCreate_synchronizedHashMap          28   ops/us
+ * }
+ * + *

Key findings: + * + *

    + *
  • {@code ConcurrentHashtable} and {@code Support} are neck-and-neck on {@code get} (1452 vs + * 1450 ops/us); both avoid the {@link Key2} wrapper allocation that {@code ConcurrentHashMap} + * requires on every lookup. + *
  • {@code ConcurrentHashMap} is ~2× slower than {@code ConcurrentHashtable} on {@code get} + * (777 vs 1452 ops/us) — the {@link Key2} allocation plus two-level hash lookup adds up. + *
  • {@code Support} shows slightly higher {@code getOrCreate} throughput than {@code D2} (1379 + * vs 1119 ops/us) because its primitive {@code int} K2 field avoids boxing inside the entry + * match on the write-path re-check. + *
  • {@code ConcurrentSkipListMap} is ~5× slower than {@code ConcurrentHashMap} due to tree + * traversal; the two-traversal {@code getOrCreate} pattern adds further overhead on misses. + *
  • Synchronized {@code HashMap} is ~50× slower than {@code ConcurrentHashtable}. + *
+ */ +@Fork(2) +@Warmup(iterations = 2) +@Measurement(iterations = 3) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(MICROSECONDS) +@Threads(8) +public class ThreadSafeMapD2Benchmark { + + static final int N_KEYS = 64; + static final int CAPACITY = 128; + + static final String[] SOURCE_K1 = new String[N_KEYS]; + static final Integer[] SOURCE_K2 = new Integer[N_KEYS]; + static final int[] SOURCE_K2_INT = new int[N_KEYS]; + + static { + for (int i = 0; i < N_KEYS; ++i) { + SOURCE_K1[i] = "key-" + i; + SOURCE_K2_INT[i] = i * 31 + 17; + SOURCE_K2[i] = SOURCE_K2_INT[i]; + } + } + + static final class D2Entry extends ConcurrentHashtable.D2.Entry { + final long value; + + D2Entry(String k1, Integer k2) { + super(k1, k2); + this.value = 1L; + } + } + + /** + * Entry used with the static helpers. Its primitive second key keeps storage and lookup unboxed, + * independently of {@link Integer} caching or JVM escape analysis. + */ + static final class SupportEntry extends ConcurrentHashtable.Entry { + final String k1; + final int k2; + final long value; + + SupportEntry(String k1, int k2) { + super(hash(k1, k2)); + this.k1 = k1; + this.k2 = k2; + this.value = 1L; + } + + static long hash(String k1, int k2) { + return LongHashingUtils.hash(k1.hashCode(), Integer.hashCode(k2)); + } + + boolean matches(String k1, int k2) { + return this.k2 == k2 && this.k1.equals(k1); + } + } + + /** Composite key for map-based baselines. */ + static final class Key2 implements Comparable { + final String k1; + final Integer k2; + final int hash; + + Key2(String k1, Integer k2) { + this.k1 = k1; + this.k2 = k2; + // Varargs-free hash: Objects.hash(k1, k2) would allocate an Object[] per key, penalizing the + // map baselines with an allocation the wrapper itself doesn't need and overstating the + // ConcurrentHashtable advantage this benchmark measures. + this.hash = 31 * k1.hashCode() + k2.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Key2)) { + return false; + } + Key2 other = (Key2) o; + return Objects.equals(k1, other.k1) && Objects.equals(k2, other.k2); + } + + @Override + public int hashCode() { + return hash; + } + + @Override + public int compareTo(Key2 other) { + int c = k1.compareTo(other.k1); + return c != 0 ? c : k2.compareTo(other.k2); + } + } + + /** + * Shared state ({@link Scope#Benchmark}): one instance of each map across all threads, modelling + * a shared instrumentation cache. + */ + @State(Scope.Benchmark) + public static class SharedState { + ConcurrentHashtable.D2 table; + java.util.concurrent.atomic.AtomicReferenceArray supportBuckets; + ConcurrentHashMap concurrentHashMap; + ConcurrentSkipListMap skipListMap; + Map synchronizedHashMap; + + @Setup(Level.Iteration) + public void setUp() { + table = ConcurrentHashtable.D2.createBounded(D2Entry.class, CAPACITY); + supportBuckets = ConcurrentHashtable.createFixedBuckets(SupportEntry.class, CAPACITY); + concurrentHashMap = new ConcurrentHashMap<>(CAPACITY); + skipListMap = new ConcurrentSkipListMap<>(); + synchronizedHashMap = Collections.synchronizedMap(new HashMap<>(CAPACITY)); + for (int i = 0; i < N_KEYS; ++i) { + int k2 = SOURCE_K2[i]; + table.tryGetOrCreateOrNull(SOURCE_K1[i], SOURCE_K2[i], D2Entry::new); + // populate support table + SupportEntry se = new SupportEntry(SOURCE_K1[i], k2); + synchronized (ConcurrentHashtable.getWriteLock(supportBuckets, se.keyHash)) { + ConcurrentHashtable.insertHeadEntryFor(supportBuckets, se.keyHash, se); + } + Key2 key = new Key2(SOURCE_K1[i], SOURCE_K2[i]); + concurrentHashMap.put(key, (long) i); + skipListMap.put(key, (long) i); + synchronizedHashMap.put(key, (long) i); + } + } + } + + /** Per-thread cursor so each thread cycles through keys independently. */ + @State(Scope.Thread) + public static class ThreadState { + int cursor; + + int next() { + int i = cursor; + cursor = (i + 1) & (N_KEYS - 1); + return i; + } + } + + @Benchmark + public D2Entry get_concurrentHashtable(SharedState s, ThreadState t) { + int i = t.next(); + return s.table.get(SOURCE_K1[i], SOURCE_K2[i]); + } + + @Benchmark + public SupportEntry get_support(SharedState s, ThreadState t) { + int i = t.next(); + String k1 = SOURCE_K1[i]; + int k2 = SOURCE_K2_INT[i]; + long keyHash = SupportEntry.hash(k1, k2); + for (SupportEntry e = ConcurrentHashtable.bucketFor(s.supportBuckets, keyHash); + e != null; + e = e.next()) { + if (e.keyHash == keyHash && e.matches(k1, k2)) { + return e; + } + } + return null; + } + + @Benchmark + public Long get_concurrentHashMap(SharedState s, ThreadState t) { + int i = t.next(); + return s.concurrentHashMap.get(new Key2(SOURCE_K1[i], SOURCE_K2[i])); + } + + @Benchmark + public Long get_concurrentSkipListMap(SharedState s, ThreadState t) { + int i = t.next(); + return s.skipListMap.get(new Key2(SOURCE_K1[i], SOURCE_K2[i])); + } + + @Benchmark + public Long get_synchronizedHashMap(SharedState s, ThreadState t) { + int i = t.next(); + return s.synchronizedHashMap.get(new Key2(SOURCE_K1[i], SOURCE_K2[i])); + } + + @Benchmark + public D2Entry getOrCreate_concurrentHashtable(SharedState s, ThreadState t) { + int i = t.next(); + return s.table.tryGetOrCreateOrNull(SOURCE_K1[i], SOURCE_K2[i], D2Entry::new); + } + + @Benchmark + public SupportEntry getOrCreate_support(SharedState s, ThreadState t) { + int i = t.next(); + String k1 = SOURCE_K1[i]; + int k2 = SOURCE_K2_INT[i]; + long keyHash = SupportEntry.hash(k1, k2); + int index = ConcurrentHashtable.bucketIndex(s.supportBuckets, keyHash); + for (SupportEntry e = ConcurrentHashtable.bucketAt(s.supportBuckets, index); + e != null; + e = e.next()) { + if (e.keyHash == keyHash && e.matches(k1, k2)) { + return e; + } + } + synchronized (ConcurrentHashtable.getWriteLockAt(s.supportBuckets, index)) { + for (SupportEntry e = ConcurrentHashtable.bucketAt(s.supportBuckets, index); + e != null; + e = e.next()) { + if (e.keyHash == keyHash && e.matches(k1, k2)) { + return e; + } + } + SupportEntry newEntry = new SupportEntry(k1, k2); + ConcurrentHashtable.insertHeadEntryAt(s.supportBuckets, index, newEntry); + return newEntry; + } + } + + /** + * get-first pattern for CHM to avoid capturing-lambda allocation on hits — the idiomatic + * equivalent of D2.getOrCreate on a mostly-populated table. + */ + @Benchmark + public Long getOrCreate_concurrentHashMap(SharedState s, ThreadState t) { + int i = t.next(); + Key2 key = new Key2(SOURCE_K1[i], SOURCE_K2[i]); + Long existing = s.concurrentHashMap.get(key); + if (existing != null) { + return existing; + } + return s.concurrentHashMap.computeIfAbsent(key, k -> 0L); + } + + /** + * get-first pattern for ConcurrentSkipListMap — manual get-then-putIfAbsent since CSLM has no + * computeIfAbsent. Two traversals on miss; one on hit. + */ + @Benchmark + public Long getOrCreate_concurrentSkipListMap(SharedState s, ThreadState t) { + int i = t.next(); + Key2 key = new Key2(SOURCE_K1[i], SOURCE_K2[i]); + Long existing = s.skipListMap.get(key); + if (existing != null) { + return existing; + } + Long prev = s.skipListMap.putIfAbsent(key, 0L); + return prev != null ? prev : 0L; + } + + /** + * get-first pattern for synchronized HashMap. On hit: one lock acquire/release for get. On miss: + * a second synchronized block for the double-checked put. + */ + @Benchmark + public Long getOrCreate_synchronizedHashMap(SharedState s, ThreadState t) { + int i = t.next(); + Key2 key = new Key2(SOURCE_K1[i], SOURCE_K2[i]); + Long existing = s.synchronizedHashMap.get(key); + if (existing != null) { + return existing; + } + synchronized (s.synchronizedHashMap) { + return s.synchronizedHashMap.computeIfAbsent(key, k -> 0L); + } + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java b/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java index b7ad3cb0eb0..39d600a421b 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java @@ -1,16 +1,21 @@ package datadog.trace.api.telemetry; -import datadog.trace.util.HashingUtils; +import static datadog.trace.util.ConcurrentHashtable.bucketAt; +import static datadog.trace.util.ConcurrentHashtable.bucketIndex; +import static datadog.trace.util.ConcurrentHashtable.estimateSize; +import static datadog.trace.util.ConcurrentHashtable.getTableWriteLock; +import static datadog.trace.util.ConcurrentHashtable.insertReserved; +import static datadog.trace.util.ConcurrentHashtable.isFull; +import static datadog.trace.util.LongHashingUtils.hash; + +import datadog.trace.util.ConcurrentHashtable; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.Iterator; import java.util.List; -import java.util.Map; import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; import javax.annotation.Nullable; import org.slf4j.Marker; import org.slf4j.MarkerFactory; @@ -20,8 +25,7 @@ public class LogCollector { public static final Marker EXCLUDE_TELEMETRY = MarkerFactory.getMarker("EXCLUDE_TELEMETRY"); private static final int DEFAULT_MAX_CAPACITY = 10; private static final LogCollector INSTANCE = new LogCollector(); - private final Map rawLogMessages; - private final int maxCapacity; + private final ConcurrentHashtable.State rawLogMessages; public static LogCollector get() { return INSTANCE; @@ -35,8 +39,7 @@ private LogCollector() { value = "SING_SINGLETON_HAS_NONPRIVATE_CONSTRUCTOR", justification = "Usage in tests") LogCollector(int maxCapacity) { - this.maxCapacity = maxCapacity; - this.rawLogMessages = new ConcurrentHashMap<>(maxCapacity); + this.rawLogMessages = ConcurrentHashtable.State.createBounded(RawLogMessage.class, maxCapacity); } public void addLogMessage(String logLevel, String message, @Nullable Throwable throwable) { @@ -54,41 +57,88 @@ public void addLogMessage(String logLevel, String message, @Nullable Throwable t */ public void addLogMessage( String logLevel, String message, @Nullable Throwable throwable, @Nullable String tags) { - if (rawLogMessages.size() >= maxCapacity) { + if (isFull(rawLogMessages)) { // TODO: We could emit a metric for dropped logs. return; } - RawLogMessage rawLogMessage = - new RawLogMessage(logLevel, message, throwable, tags, System.currentTimeMillis() / 1000); - AtomicInteger count = rawLogMessages.computeIfAbsent(rawLogMessage, k -> new AtomicInteger()); - count.incrementAndGet(); + + long keyHash = RawLogMessage.computeHash(logLevel, message, throwable); + int index = bucketIndex(rawLogMessages.buckets, keyHash); + RawLogMessage rawLogMessage = find(index, keyHash, logLevel, message, throwable); + if (rawLogMessage != null) { + rawLogMessage.increment(); + return; + } + + synchronized (getTableWriteLock(rawLogMessages)) { + rawLogMessage = find(index, keyHash, logLevel, message, throwable); + if (rawLogMessage != null) { + rawLogMessage.increment(); + return; + } + if (isFull(rawLogMessages)) { + return; + } + + rawLogMessage = + new RawLogMessage(logLevel, message, throwable, tags, System.currentTimeMillis() / 1000); + if (rawLogMessages.sizeManager.tryReserve()) { + insertReserved(rawLogMessages, keyHash, rawLogMessage); + } + } } public Collection drain() { - if (rawLogMessages.isEmpty()) { + int size = estimateSize(rawLogMessages); + if (size == 0) { return Collections.emptyList(); } - List list = new ArrayList<>(rawLogMessages.size()); - Iterator> iterator = - rawLogMessages.entrySet().iterator(); - - while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); - RawLogMessage logMessage = entry.getKey(); - // XXX: There might be lost writers to the counters under concurrency if another thread - // increments it - // while we are reading it here. At the moment, we are not overdoing this to prevent some - // counter losses. - logMessage.count = entry.getValue().get(); - iterator.remove(); - list.add(logMessage); - } - + List list = new ArrayList<>(size); + ConcurrentHashtable.drain( + rawLogMessages, + list, + (drained, logMessage) -> { + // A writer that found this entry before drain detached it can still increment too late. + logMessage.snapshotCount(); + drained.add(logMessage); + }); return list; } - public static final class RawLogMessage { + @Nullable + private RawLogMessage find( + int index, long keyHash, String logLevel, String message, @Nullable Throwable throwable) { + StackTraceElement[] stackTrace = null; + for (RawLogMessage entry = bucketAt(rawLogMessages, index); + entry != null; + entry = entry.next()) { + if (entry.keyHash != keyHash + || !Objects.equals(logLevel, entry.logLevel) + || !Objects.equals(message, entry.message)) { + continue; + } + if (throwable == entry.throwable) { + return entry; + } + if (throwable != null + && entry.throwable != null + && throwable.getClass().equals(entry.throwable.getClass())) { + if (stackTrace == null) { + stackTrace = throwable.getStackTrace(); + } + if (Objects.deepEquals(stackTrace, entry.stackTrace())) { + return entry; + } + } + } + return null; + } + + public static final class RawLogMessage extends ConcurrentHashtable.Entry { + private static final AtomicIntegerFieldUpdater DEDUP_COUNT = + AtomicIntegerFieldUpdater.newUpdater(RawLogMessage.class, "dedupCount"); + public final String message; public final String logLevel; public final Throwable throwable; @@ -96,10 +146,12 @@ public static final class RawLogMessage { public final long timestamp; public int count; + private volatile int dedupCount = 1; private StackTraceElement[] cachedStackTrace = null; public RawLogMessage( String logLevel, String message, Throwable throwable, String tags, long timestamp) { + super(computeHash(logLevel, message, throwable)); this.logLevel = logLevel; this.message = message; this.throwable = throwable; @@ -122,6 +174,14 @@ public StackTraceElement[] stackTrace() { return stackTrace; } + private void increment() { + DEDUP_COUNT.incrementAndGet(this); + } + + private void snapshotCount() { + count = DEDUP_COUNT.get(this); + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -149,7 +209,12 @@ public boolean equals(Object o) { @Override public int hashCode() { - return HashingUtils.hash(logLevel, message, throwable == null ? null : throwable.getClass()); + return (int) keyHash; + } + + private static long computeHash( + String logLevel, String message, @Nullable Throwable throwable) { + return hash(logLevel, message, throwable == null ? null : throwable.getClass()); } } } diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java new file mode 100644 index 00000000000..f07ae879280 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -0,0 +1,1316 @@ +package datadog.trace.util; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReferenceArray; +import java.util.function.BiConsumer; +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; +import javax.annotation.concurrent.GuardedBy; +import javax.annotation.concurrent.ThreadSafe; + +/** + * Fixed-capacity concurrent hash tables with lock-free reads and serialized writes. + * + *

{@link D1} accepts one key. {@link D2} accepts two key parts directly. Both store + * caller-defined {@link Entry} objects in separate-chained buckets and never resize. + * + *

Bucket heads live in an {@link AtomicReferenceArray}. Its volatile {@code set}/{@code get} + * semantics publish an inserted entry and its initialized fields to lock-free readers. The volatile + * {@code next} links likewise make chain splices visible. A read racing a removal may observe + * either state; removed entries retain their link so a reader already on the chain can still finish + * traversing it. + * + *

{@link D2} structurally avoids a temporary composite key. With a conventional concurrent map, + * that wrapper may be retained on insertion, so HotSpot cannot reliably scalar-replace it through + * escape analysis. Avoiding the wrapper matters on the tracer's hot lookup paths. + * + *

{@link D1} and {@link D2} manage locking and capacity internally. For primitive or + * higher-arity keys, subclass {@link Entry} and use the static helpers. {@link #bucketFor}, {@link + * #bucketAt}, and {@link #forEach} are lock-free. {@link #removeIf}, {@link #drain}, and {@link + * #clear} acquire the table write lock internally. Follow each mutation helper's locking contract + * and treat the monitor returned by the lock helpers as opaque. + */ +public final class ConcurrentHashtable { + private ConcurrentHashtable() {} + + /** + * Internal base class for concurrent entries. Stores the precomputed 64-bit keyHash and a {@code + * volatile} chain-next pointer used to link colliding entries within a single bucket. + * + *

The {@code next} pointer is {@code volatile} (unlike {@link Hashtable.Entry}) so that chain + * splices performed by {@link D1#remove}/{@link D2#remove} are visible to lock-free readers. + * + *

Subclasses add the key field(s) and a {@code matches(...)} method tailored to their key + * arity. See {@link D1.Entry} and {@link D2.Entry}; for higher arities, or for primitive key + * components, subclass this directly and drive the table with the static building blocks on + * {@link ConcurrentHashtable}. + */ + public abstract static class Entry { + public final long keyHash; + private volatile Entry next = null; + + protected Entry(long keyHash) { + this.keyHash = keyHash; + } + + // Package-private: the only writers are the static insert/remove building blocks + // (insertHeadEntry, unlink) on the enclosing class, which reach it via the Entry bound. Custom + // tables mutate chains through those helpers, never by touching next directly. + final void setNext(TEntry next) { + this.next = next; + } + + @SuppressWarnings("unchecked") + @Nullable + public final TEntry next() { + return (TEntry) this.next; + } + } + + /** + * Single-key concurrent hash table. Lock-free on hit; locked on miss/mutation. + * + * @param the key type + * @param the user's {@link D1.Entry D1.Entry<K>} subclass + */ + @ThreadSafe + public static final class D1> { + + /** + * Abstract base for {@link D1} entries. Subclass to add value fields you wish to mutate in + * place after retrieving the entry via {@link D1#get}. + * + * @param the key type + */ + public abstract static class Entry extends ConcurrentHashtable.Entry { + final 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(@Nullable Object key) { + // equals() on the lookup param, not the field, so the JIT can devirtualize it once + // matches() inlines into get/getOrCreate (the caller's key type is known there). + return Objects.equals(key, this.key); + } + + /** + * Returns the 64-bit lookup hash for {@code key}. Null keys map to {@link Long#MIN_VALUE} so + * they don't collide with a real key that hashes to 0; real-key collisions in chains are + * resolved by {@link #matches(Object)}. + */ + public static long hash(@Nullable Object key) { + return (key == null) ? Long.MIN_VALUE : key.hashCode(); + } + } + + private final State state; + + private D1(State state) { + this.state = state; + } + + /** + * Creates a fixed-size table holding at most {@code maxCapacity} entries. {@code entryClass} is + * used only to infer the concrete entry type; entries are created by the functions passed to + * the insertion methods. The table does not resize. + */ + @Nonnull + public static > D1 createBounded( + @Nonnull Class entryClass, int maxCapacity) { + return new D1<>(State.createBounded(entryClass, maxCapacity)); + } + + public int size() { + return state.sizeManager.estimateSize(); + } + + public boolean isFull() { + return state.sizeManager.isFull(); + } + + @Nullable + public TEntry get(@Nullable K key) { + long keyHash = D1.Entry.hash(key); + for (TEntry curEntry = bucketFor(state, keyHash); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return curEntry; + } + } + return null; + } + + /** + * Returns the entry for {@code key}, creating one via {@code creator} if absent and the table + * is under capacity. Lock-free on hit; acquires a table-level lock on miss. Wraps {@link + * #tryGetOrCreateOrNull} — see that method for the refusal and ordering details. + */ + @Nonnull + public Maybe tryGetOrCreate( + @Nullable K key, @Nonnull Function creator) { + return Maybe.of(tryGetOrCreateOrNull(key, creator)); + } + + /** + * Escape hatch for {@link #tryGetOrCreate} for callers that want the nullable entry directly + * rather than a {@link Maybe} wrapper. Returns {@code null} when the table is at capacity and + * {@code key} was not already present. Re-checks under the lock to avoid duplicate entries + * under concurrent misses. + */ + @Nullable + public TEntry tryGetOrCreateOrNull( + @Nullable K key, @Nonnull Function creator) { + long keyHash = D1.Entry.hash(key); + int index = bucketIndex(state.buckets, keyHash); + for (TEntry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return curEntry; + } + } + synchronized (getTableWriteLock(state)) { + for (TEntry curEntry = bucketAt(state, index); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return curEntry; + } + } + // isFull() is checked before creating the entry, not before reserving a slot for it: + // creator.apply() can throw, and if we'd already reserved (incremented) the slot, a + // throwing creator would leak that reservation forever. So we accept the entry only after + // creator succeeds, then increment. + if (state.sizeManager.isFull()) { + return null; + } + TEntry newEntry = creator.apply(key); + insertHeadEntryAt(state, index, newEntry); + state.sizeManager.increment(); + return newEntry; + } + } + + /** + * {@link #tryGetOrCreate}, but when the table is full, evicts one entry matching {@code + * evictable} to make room instead of refusing the insert. Refuses only when the table is full + * and nothing matches {@code evictable} — 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); + int index = bucketIndex(state.buckets, keyHash); + for (TEntry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return curEntry; + } + } + synchronized (getTableWriteLock(state)) { + for (TEntry curEntry = bucketAt(state, index); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return curEntry; + } + } + if (state.sizeManager.isFull() + && state.sizeManager.evictOne(state.buckets, evictable) == null) { + return null; + } + TEntry newEntry = creator.apply(key); + insertHeadEntryAt(state, index, newEntry); + state.sizeManager.increment(); + return newEntry; + } + } + + /** + * Removes and returns the entry for {@code key}, or {@code null} if absent. Acquires the + * table-level lock to splice the chain; lock-free readers observe the removal via the volatile + * write of the predecessor's {@code next} (or the bucket head). + */ + @Nullable + public TEntry remove(@Nullable K key) { + long keyHash = D1.Entry.hash(key); + int index = bucketIndex(state.buckets, keyHash); + synchronized (getTableWriteLock(state)) { + TEntry prev = null; + for (TEntry curEntry = bucketAt(state, index); + curEntry != null; + prev = curEntry, curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + unlink(state, index, prev, curEntry); + state.sizeManager.decrement(); + return curEntry; + } + } + return null; + } + } + + /** + * Removes every entry matching {@code predicate}, returning {@code true} if any were removed. + * Holds the table-level lock for the whole sweep, so the predicate sees a stable table and + * concurrent writers are excluded; lock-free readers continue throughout. + */ + public boolean removeIf(@Nonnull Predicate predicate) { + return ConcurrentHashtable.removeIf(state, predicate); + } + + /** + * Removes all entries and passes each one to {@code sink} while holding the table write lock. + * The sink should be quick and must not throw. If it throws, the partial drain is not rolled + * back and the size is not adjusted. + * + *

Use {@link #drain(Object, BiConsumer)} to avoid a capturing lambda. + */ + public void drain(@Nonnull Consumer sink) { + ConcurrentHashtable.drain(state, sink); + } + + /** + * Context-passing {@link #drain(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 void drain(C context, @Nonnull BiConsumer sink) { + ConcurrentHashtable.drain(state, context, sink); + } + + /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */ + public void clear() { + ConcurrentHashtable.clear(state); + } + + public void forEach(@Nonnull Consumer consumer) { + ConcurrentHashtable.forEach(state, consumer); + } + + /** + * Context-passing forEach. Avoids a capturing-lambda allocation — pass a non-capturing {@link + * BiConsumer} (typically a {@code static final}) plus whatever side-band state it needs. + */ + public void forEach(C context, @Nonnull BiConsumer consumer) { + ConcurrentHashtable.forEach(state, context, consumer); + } + } + + /** + * Two-key concurrent hash table. Key parts are passed directly to {@link #get} and {@link + * #tryGetOrCreate}, avoiding a composite wrapper whose allocation would otherwise rely on HotSpot + * escape analysis to disappear. Reads are lock-free; misses and mutations acquire the write lock. + * + * @param first key type + * @param second key type + * @param the user's {@link D2.Entry D2.Entry<K1, K2>} subclass + */ + @ThreadSafe + public static final class D2> { + + /** + * Abstract base for {@link D2} entries. Subclass to add value fields you wish to mutate in + * place. + * + * @param first key type + * @param second key type + */ + public abstract static class Entry extends ConcurrentHashtable.Entry { + final K1 key1; + final 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(@Nullable K1 key1, @Nullable K2 key2) { + // equals() on the lookup params, not the fields, so the JIT can devirtualize them once + // matches() inlines into get/getOrCreate (the caller's key types are known there). + return Objects.equals(key1, this.key1) && Objects.equals(key2, this.key2); + } + + /** Returns the 64-bit lookup hash combining both key parts via {@link LongHashingUtils}. */ + public static long hash(@Nullable Object key1, @Nullable Object key2) { + return LongHashingUtils.hash(key1, key2); + } + } + + private final State state; + + private D2(State state) { + this.state = state; + } + + /** + * Creates a fixed-size table holding at most {@code maxCapacity} entries. {@code entryClass} is + * used only to infer the concrete entry type; entries are created by the functions passed to + * the insertion methods. The table does not resize. + */ + @Nonnull + public static > D2 createBounded( + @Nonnull Class entryClass, int maxCapacity) { + return new D2<>(State.createBounded(entryClass, maxCapacity)); + } + + public int size() { + return state.sizeManager.estimateSize(); + } + + public boolean isFull() { + return state.sizeManager.isFull(); + } + + @Nullable + public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { + long keyHash = D2.Entry.hash(key1, key2); + for (TEntry curEntry = bucketFor(state, keyHash); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return curEntry; + } + } + return null; + } + + /** + * Returns the entry for {@code (key1, key2)}, creating one via {@code creator} if absent and + * the table is under capacity. Lock-free on hit; acquires a table-level lock on miss. Wraps + * {@link #tryGetOrCreateOrNull} — see that method for the refusal and ordering details. + * + *

The {@code creator} should build an entry whose {@code keyHash} equals {@link + * D2.Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}. + */ + @Nonnull + public Maybe tryGetOrCreate( + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull BiFunction creator) { + return Maybe.of(tryGetOrCreateOrNull(key1, key2, creator)); + } + + /** + * Escape hatch for {@link #tryGetOrCreate} for callers that want the nullable entry directly + * rather than a {@link Maybe} wrapper. Returns {@code null} when the table is at capacity and + * {@code (key1, key2)} was not already present. Re-checks under the lock to avoid duplicate + * entries under concurrent misses. + */ + @Nullable + public TEntry tryGetOrCreateOrNull( + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull BiFunction creator) { + long keyHash = D2.Entry.hash(key1, key2); + int index = bucketIndex(state.buckets, keyHash); + for (TEntry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return curEntry; + } + } + synchronized (getTableWriteLock(state)) { + for (TEntry curEntry = bucketAt(state, index); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return curEntry; + } + } + // isFull() is checked before creating the entry, not before reserving a slot for it: + // creator.apply() can throw, and if we'd already reserved (incremented) the slot, a + // throwing creator would leak that reservation forever. So we accept the entry only after + // creator succeeds, then increment. + if (state.sizeManager.isFull()) { + return null; + } + TEntry newEntry = creator.apply(key1, key2); + insertHeadEntryAt(state, index, newEntry); + state.sizeManager.increment(); + return newEntry; + } + } + + /** + * {@link #tryGetOrCreate}, but when the table is full, evicts one entry matching {@code + * evictable} to make room instead of refusing the insert. Refuses only when the table is full + * and nothing matches {@code evictable} — 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); + int index = bucketIndex(state.buckets, keyHash); + for (TEntry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return curEntry; + } + } + synchronized (getTableWriteLock(state)) { + for (TEntry curEntry = bucketAt(state, index); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return curEntry; + } + } + if (state.sizeManager.isFull() + && state.sizeManager.evictOne(state.buckets, evictable) == null) { + return null; + } + TEntry newEntry = creator.apply(key1, key2); + insertHeadEntryAt(state, index, newEntry); + state.sizeManager.increment(); + return newEntry; + } + } + + /** + * Removes and returns the entry for {@code (key1, key2)}, or {@code null} if absent. Acquires + * the table-level lock to splice the chain; lock-free readers observe the removal via the + * volatile write of the predecessor's {@code next} (or the bucket head). + */ + @Nullable + public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { + long keyHash = D2.Entry.hash(key1, key2); + int index = bucketIndex(state.buckets, keyHash); + synchronized (getTableWriteLock(state)) { + TEntry prev = null; + for (TEntry curEntry = bucketAt(state, index); + curEntry != null; + prev = curEntry, curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + unlink(state, index, prev, curEntry); + state.sizeManager.decrement(); + return curEntry; + } + } + return null; + } + } + + /** + * Removes every entry matching {@code predicate}, returning {@code true} if any were removed. + * Holds the table-level lock for the whole sweep, so the predicate sees a stable table and + * concurrent writers are excluded; lock-free readers continue throughout. + */ + public boolean removeIf(@Nonnull Predicate predicate) { + return ConcurrentHashtable.removeIf(state, predicate); + } + + /** + * Removes all entries and passes each one to {@code sink} while holding the table write lock. + * The sink should be quick and must not throw. If it throws, the partial drain is not rolled + * back and the size is not adjusted. + * + *

Use {@link #drain(Object, BiConsumer)} to avoid a capturing lambda. + */ + public void drain(@Nonnull Consumer sink) { + ConcurrentHashtable.drain(state, sink); + } + + /** + * Context-passing {@link #drain(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 void drain(C context, @Nonnull BiConsumer sink) { + ConcurrentHashtable.drain(state, context, sink); + } + + /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */ + public void clear() { + ConcurrentHashtable.clear(state); + } + + public void forEach(@Nonnull Consumer consumer) { + ConcurrentHashtable.forEach(state, consumer); + } + + /** + * Context-passing forEach. Avoids a capturing-lambda allocation — pass a non-capturing {@link + * BiConsumer} (typically a {@code static final}) plus whatever side-band state it needs. + */ + public void forEach(C context, @Nonnull BiConsumer consumer) { + ConcurrentHashtable.forEach(state, context, consumer); + } + } + + /** + * Tracks a capped table's occupancy and eviction position. + * + *

The count includes live entries and outstanding reservations. Its {@link AtomicInteger} + * provides volatile visibility and atomic updates, allowing size queries and {@link + * #tryReserve()} without the table lock. The plain {@code evictionCursor} is instead protected by + * the table write lock; methods annotated with {@link GuardedBy} require that lock. + */ + @ThreadSafe + public static final class SizeManager { + private final AtomicInteger size = new AtomicInteger(); + private final 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. + */ + @GuardedBy("getTableWriteLock(buckets)") + private int evictionCursor; + + public SizeManager(int capacity) { + this.capacity = capacity; + } + + /** Live entries. Safe to call without the write lock. */ + public int estimateSize() { + return size.get(); + } + + public int capacity() { + return capacity; + } + + /** {@code true} once {@link #estimateSize()} has reached {@link #capacity()}. */ + public boolean isFull() { + return size.get() >= capacity; + } + + /** + * Atomically reserves one slot without taking the table write lock. It claims first with {@link + * AtomicInteger#incrementAndGet()} and refunds values above capacity, so concurrent callers + * cannot both acquire the last slot. Returns {@code false} with the count unchanged when the + * table is full. + * + *

Build the entry before reserving: there is no cancellation operation, so abandoning a + * successful reservation permanently consumes capacity. + */ + public boolean tryReserve() { + if (size.incrementAndGet() > capacity) { + size.decrementAndGet(); + return false; + } + return true; + } + + /** + * Reserves one slot, evicting an entry matching {@code evictable} when the table is full. + * Returns {@code false} without changing the table when no entry can be evicted. + * + *

The reservation survives concurrent drain and clear operations. The caller must fill it; + * an abandoned reservation permanently consumes capacity. + */ + @GuardedBy("getTableWriteLock(buckets)") + public boolean tryReserveOrEvict( + @Nonnull AtomicReferenceArray buckets, + @Nonnull Predicate evictable) { + if (tryReserve()) { + return true; + } + if (evictOne(buckets, evictable) == null) { + return false; + } + // evictOne already decremented; the slot it freed is ours. + size.incrementAndGet(); + return true; + } + + /** Call after successfully linking a new entry. */ + public void increment() { + size.incrementAndGet(); + } + + /** Call after successfully unlinking an entry. */ + public void decrement() { + size.decrementAndGet(); + } + + /** + * Releases {@code removed} slots after a sweep and resets the {@code evictionCursor}. + * Outstanding reservations remain counted. + */ + @GuardedBy("getTableWriteLock(buckets)") + @SuppressFBWarnings( + value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", + justification = + "evictionCursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs" + + " cannot model that dynamic guard") + public void release(int removed) { + if (removed != 0) { + size.addAndGet(-removed); + } + evictionCursor = 0; + } + + /** + * Removes and returns the first entry matching {@code evictable}, scanning from the previous + * eviction position and wrapping once. Returns {@code null} without changing the count when no + * entry matches. + * + *

This operation may inspect every live entry while holding the table write lock, so the + * predicate should be quick. + */ + @GuardedBy("getTableWriteLock(buckets)") + @Nullable + public TEntry evictOne( + @Nonnull AtomicReferenceArray buckets, + @Nonnull Predicate evictable) { + TEntry evicted = evictOneInRange(buckets, evictable, evictionCursor, buckets.length()); + if (evicted == null && evictionCursor != 0) { + evicted = evictOneInRange(buckets, evictable, 0, evictionCursor); + } + if (evicted != null) { + size.decrementAndGet(); + return evicted; + } + // Nothing matched anywhere; step the evictionCursor on regardless so repeated refusals don't + // all + // restart the (wasted) scan from the same bucket. + evictionCursor = bucketIndex(buckets, evictionCursor + 1); + return null; + } + + @GuardedBy("getTableWriteLock(buckets)") + @SuppressFBWarnings( + value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", + justification = + "evictionCursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs" + + " cannot model that dynamic guard") + @Nullable + private TEntry evictOneInRange( + @Nonnull AtomicReferenceArray buckets, + @Nonnull Predicate evictable, + int startBucket, + int endBucket) { + for (int i = startBucket; i < endBucket; i++) { + TEntry prev = null; + for (TEntry e = buckets.get(i); e != null; e = e.next()) { + if (evictable.test(e)) { + unlink(buckets, i, prev, e); + evictionCursor = i; + return e; + } + prev = e; + } + } + return null; + } + + /** + * 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. + */ + @GuardedBy("getTableWriteLock(buckets)") + @SuppressFBWarnings( + value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", + justification = + "evictionCursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs" + + " cannot model that dynamic guard") + public int evictAll( + @Nonnull AtomicReferenceArray buckets, + @Nonnull Predicate evictable) { + int count = 0; + for (int i = 0; i < buckets.length(); i++) { + TEntry prev = null; + for (TEntry e = buckets.get(i); e != null; e = e.next()) { + if (evictable.test(e)) { + unlink(buckets, i, prev, e); + size.decrementAndGet(); + count++; + } else { + prev = e; + } + } + } + evictionCursor = 0; + return count; + } + } + + /** + * Bucket array and occupancy manager for a caller-defined capped table. Keep them paired and + * prefer the {@code State}-accepting helpers so structural changes update the count consistently. + */ + public static final class State { + public final AtomicReferenceArray buckets; + public final SizeManager sizeManager; + + private State(AtomicReferenceArray buckets, int maxCapacity) { + this.buckets = buckets; + this.sizeManager = new SizeManager(maxCapacity); + } + + /** + * Creates a bucket array for {@code maxCapacity} entries and pairs it with a manager enforcing + * that cap. {@code entryClass} is used only to infer {@code TEntry}. + */ + @Nonnull + public static State createBounded( + @Nonnull Class entryClass, int maxCapacity) { + return new State<>(createFixedBuckets(entryClass, maxCapacity), maxCapacity); + } + } + + /** Live entries in {@code state}; see {@link SizeManager#estimateSize()}. Lock-free. */ + public static int estimateSize(@Nonnull State state) { + return state.sizeManager.estimateSize(); + } + + /** + * {@code true} once {@code state} is at capacity; see {@link SizeManager#isFull()}. Lock-free. + */ + public static boolean isFull(@Nonnull State state) { + return state.sizeManager.isFull(); + } + + /** + * Reserves one slot in {@code state}, evicting an entry matching {@code evictable} when + * necessary. Returns {@code false} if the table is full and nothing can be evicted. This method + * acquires the table write lock. + * + *

The reservation survives drain and clear operations. Complete it with {@link + * #insertReserved}; abandoning it permanently consumes capacity. + */ + public static boolean tryReserveOrEvict( + @Nonnull State state, @Nonnull Predicate evictable) { + synchronized (getTableWriteLock(state)) { + 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. + * Self-locking. + */ + @Nullable + public static TEntry evictOne( + @Nonnull State state, @Nonnull Predicate evictable) { + synchronized (getTableWriteLock(state)) { + return state.sizeManager.evictOne(state.buckets, evictable); + } + } + + /** + * Unlinks every entry in {@code state} matching {@code evictable}, decrementing per removal, and + * returns how many went. Self-locking. + */ + public static int evictAll( + @Nonnull State state, @Nonnull Predicate evictable) { + synchronized (getTableWriteLock(state)) { + return state.sizeManager.evictAll(state.buckets, evictable); + } + } + + // --------------------------------------------------------------------------------------------- + // Static building blocks over a caller-owned bucket array (formerly the nested Support class). + // Use these to assemble a custom table (higher arity, primitive keys, extra value fields) when + // D1/D2 don't fit; D1/D2 delegate to them internally. The whole-table mutators (removeIf, drain, + // clear) self-lock on the array; the single-slot write primitives (insertHeadEntry, unlink) do + // not lock and must be called under the caller's own synchronized (getWriteLock(buckets, + // keyHash)) block. + // Readers + // (bucket walks, forEach) are lock-free. + // --------------------------------------------------------------------------------------------- + + /** + * Creates a bucket array whose length is {@link #sizeFor(int) sizeFor(capacity)}. Because {@link + * AtomicReferenceArray}'s element type is erased at runtime, {@code entryClass} is not used for + * reflective allocation or runtime type checks; it only lets the compiler infer {@code TEntry}. + */ + @Nonnull + public static AtomicReferenceArray createFixedBuckets( + @Nonnull Class entryClass, int capacity) { + return new AtomicReferenceArray<>(sizeFor(capacity)); + } + + /** Upper bound on the bucket-array length returned by {@link #sizeFor(int)}. */ + static final int MAX_BUCKETS = 1 << 30; + + /** + * Returns the bucket-array length to allocate for a table sized to hold {@code requestedSize} + * entries: {@code requestedSize} rounded up to the next power of two, capped at {@link + * #MAX_BUCKETS}. Throws {@link IllegalArgumentException} for negative inputs or inputs above the + * cap. + */ + public static int sizeFor(int requestedSize) { + if (requestedSize < 0) { + throw new IllegalArgumentException("requestedSize must be non-negative: " + requestedSize); + } + if (requestedSize > MAX_BUCKETS) { + throw new IllegalArgumentException( + "requestedSize exceeds maximum bucket count (" + MAX_BUCKETS + "): " + requestedSize); + } + if (requestedSize <= 1) { + return 1; + } + return Integer.highestOneBit(requestedSize - 1) << 1; + } + + /** + * Returns the opaque monitor guarding writes to the bucket selected by {@code keyHash}. Use this + * monitor for a keyed scan-and-mutate operation; do not depend on its identity or granularity. + * + * @see #getWriteLockAt(AtomicReferenceArray, int) + * @see #getTableWriteLock(AtomicReferenceArray) + */ + @Nonnull + public static Object getWriteLock(@Nonnull AtomicReferenceArray buckets, long keyHash) { + return getWriteLockAt(buckets, bucketIndex(buckets, keyHash)); + } + + /** {@link #getWriteLock(AtomicReferenceArray, long)} over a {@link State}. */ + @Nonnull + public static Object getWriteLock(@Nonnull State state, long keyHash) { + return getWriteLock(state.buckets, keyHash); + } + + /** + * {@link #getWriteLock(AtomicReferenceArray, long)} for a bucket index that has already been + * computed — the shape a {@code getOrCreate} wants when it reuses the index from its lock-free + * pre-check. This is the primitive; the {@code keyHash} form maps the hash through {@link + * #bucketIndex} and calls it. + */ + @Nonnull + public static Object getWriteLockAt(@Nonnull AtomicReferenceArray buckets, int bucketIndex) { + return buckets; + } + + /** {@link #getWriteLockAt(AtomicReferenceArray, int)} over a {@link State}. */ + @Nonnull + public static Object getWriteLockAt(@Nonnull State state, int bucketIndex) { + return getWriteLockAt(state.buckets, bucketIndex); + } + + /** + * Returns the opaque monitor guarding operations that span all buckets. Whole-table helpers such + * as {@link #drain}, {@link #clear}, and {@link #removeIf} acquire this monitor internally. + */ + @Nonnull + public static Object getTableWriteLock(@Nonnull AtomicReferenceArray buckets) { + return buckets; + } + + /** {@link #getTableWriteLock(AtomicReferenceArray)} over a {@link State}. */ + @Nonnull + public static Object getTableWriteLock(@Nonnull State state) { + return getTableWriteLock(state.buckets); + } + + public static int bucketIndex(@Nonnull AtomicReferenceArray buckets, long keyHash) { + // Bucket lengths are powers of two, so masking replaces a more expensive modulo operation. + return (int) (keyHash & (buckets.length() - 1)); + } + + /** + * Returns the head entry of the bucket that {@code keyHash} maps to. The bucket read is a + * volatile read of the slot, so it is safe from any thread without a lock. + * + *

Named distinctly from {@link #bucketAt} (rather than overloaded on {@code long} vs. {@code + * int}) deliberately: a caller with a primitive {@code int}-typed key hash that called an + * overloaded {@code bucket(buckets, intHash)} would silently bind to the {@code int}-index + * overload instead of widening to this one, reading the raw hash as an array index — out-of-range + * hashes throw {@link IndexOutOfBoundsException}, in-range-but-wrong ones silently read the wrong + * bucket. + */ + @Nullable + public static TEntry bucketFor( + @Nonnull AtomicReferenceArray buckets, long keyHash) { + return buckets.get(bucketIndex(buckets, keyHash)); + } + + /** {@link #bucketFor(AtomicReferenceArray, long)} over a {@link State}. */ + @Nullable + public static TEntry bucketFor( + @Nonnull State state, long keyHash) { + return bucketFor(state.buckets, keyHash); + } + + /** + * Returns the head entry of the bucket at {@code index}. Use when the bucket index is already + * computed (e.g. inside {@code getOrCreate} where the same index is reused across the lock + * boundary). See {@link #bucketFor} for why this is a distinct name rather than an {@code int} + * overload of it. + */ + @Nullable + public static TEntry bucketAt( + @Nonnull AtomicReferenceArray buckets, int index) { + return buckets.get(index); + } + + /** {@link #bucketAt(AtomicReferenceArray, int)} over a {@link State}. */ + @Nullable + public static TEntry bucketAt(@Nonnull State state, int index) { + return bucketAt(state.buckets, index); + } + + /** + * Publishes {@code entry} as the head of bucket {@code index}. The helper writes the entry's + * {@code next} link before the volatile {@link AtomicReferenceArray#set}; a volatile bucket read + * that observes the new head also sees the initialized entry and its link. The caller must hold + * {@link #getWriteLockAt}; this method does not acquire a lock or update size accounting. + * + *

The entry must be unlinked and must not be reused after removal. Removal intentionally + * retains its {@code next} link for readers already traversing that chain. + */ + @GuardedBy("getWriteLockAt(buckets, index)") + public static void insertHeadEntryAt( + @Nonnull AtomicReferenceArray buckets, int index, @Nonnull TEntry entry) { + assert Thread.holdsLock(getWriteLockAt(buckets, index)) + : "insertHeadEntryAt called without holding getWriteLockAt(buckets, index)"; + assert entry.next() == null + : "Entry already linked -- inserting the same Entry instance twice corrupts the chain" + + " (unlink() deliberately leaves a removed entry's next intact for in-flight" + + " readers, so a removed entry must never be reinserted)"; + entry.setNext(buckets.get(index)); + buckets.set(index, entry); + } + + /** {@link #insertHeadEntryAt(AtomicReferenceArray, int, Entry)} over a {@link State}. */ + @GuardedBy("getWriteLockAt(state, index)") + public static void insertHeadEntryAt( + @Nonnull State state, int index, @Nonnull TEntry entry) { + insertHeadEntryAt(state.buckets, index, entry); + } + + /** + * Convenience form of {@link #insertHeadEntryAt} that derives the bucket index from {@code + * keyHash}. Prefer {@link #insertHeadEntryAt} when the index is already computed (e.g. a {@code + * getOrCreate} that reuses it across the lock-free pre-check). + */ + @GuardedBy("getWriteLock(buckets, keyHash)") + public static void insertHeadEntryFor( + @Nonnull AtomicReferenceArray buckets, long keyHash, @Nonnull TEntry entry) { + insertHeadEntryAt(buckets, bucketIndex(buckets, keyHash), entry); + } + + /** + * Links an already-built entry after a successful {@link #tryReserveOrEvict} or {@link + * SizeManager#tryReserve()} call. This method does not acquire a lock or update the count. + * + *

Complete all fallible work before reserving. There is no cancellation operation, so an + * abandoned reservation permanently consumes capacity. Drain and clear do not cancel outstanding + * reservations. + */ + @GuardedBy("getTableWriteLock(state)") + public static void insertReserved( + @Nonnull State state, long keyHash, @Nonnull TEntry entry) { + insertHeadEntryFor(state.buckets, keyHash, entry); + } + + /** + * Splices {@code entry} out of the chain at {@code index}. {@code prev} is the in-chain + * predecessor, or {@code null} when {@code entry} is the bucket head. Re-points the predecessor + * (or the bucket head slot) past {@code entry} via a volatile write so lock-free readers see the + * removal. {@code entry}'s own {@code next} is deliberately left intact so a reader already + * positioned on it can still traverse forward. This is a single-slot primitive: it does not lock, + * so call it inside the caller's {@code synchronized (getWriteLockAt(buckets, index))} block. + * Does not touch size accounting. + */ + @GuardedBy("getWriteLockAt(buckets, index)") + public static void unlink( + @Nonnull AtomicReferenceArray buckets, + int index, + @Nullable TEntry prev, + @Nonnull TEntry entry) { + assert Thread.holdsLock(getWriteLockAt(buckets, index)) + : "unlink called without holding getWriteLockAt(buckets, index)"; + TEntry next = entry.next(); + if (prev == null) { + buckets.set(index, next); + } else { + prev.setNext(next); + } + } + + /** {@link #unlink(AtomicReferenceArray, int, Entry, Entry)} over a {@link State}. */ + @GuardedBy("getWriteLockAt(state, index)") + public static void unlink( + @Nonnull State state, int index, @Nullable TEntry prev, @Nonnull TEntry entry) { + unlink(state.buckets, index, prev, entry); + } + + /** + * Removes every entry matching {@code predicate} from {@code buckets}, decrementing {@code size} + * once per removal. Self-locking: synchronizes on {@code buckets} for the whole sweep, so the + * predicate sees a stable table and concurrent writers are excluded; lock-free readers continue + * throughout. + */ + public static boolean removeIf( + @Nonnull AtomicReferenceArray buckets, + @Nonnull AtomicInteger size, + @Nonnull Predicate predicate) { + synchronized (getTableWriteLock(buckets)) { + boolean removed = false; + for (int i = 0; i < buckets.length(); i++) { + TEntry prev = null; + for (TEntry e = buckets.get(i); e != null; e = e.next()) { + if (predicate.test(e)) { + unlink(buckets, i, prev, e); + size.decrementAndGet(); + removed = true; + // prev stays put: e is now unlinked, so the last survivor remains the predecessor. + } else { + prev = e; + } + } + } + return removed; + } + } + + /** + * {@link #removeIf(AtomicReferenceArray, AtomicInteger, Predicate)} variant for callers tracking + * occupancy with a {@link State} instead of a bare counter — used by {@link D1#removeIf} and + * {@link D2#removeIf}. + */ + public static boolean removeIf( + @Nonnull State state, @Nonnull Predicate predicate) { + AtomicReferenceArray buckets = state.buckets; + synchronized (getTableWriteLock(state)) { + boolean removed = false; + for (int i = 0; i < buckets.length(); i++) { + TEntry prev = null; + for (TEntry e = buckets.get(i); e != null; e = e.next()) { + if (predicate.test(e)) { + unlink(buckets, i, prev, e); + state.sizeManager.decrement(); + removed = true; + } else { + prev = e; + } + } + } + return removed; + } + } + + /** + * Removes all entries while holding the table write lock. Each bucket head is cleared with a + * volatile write before its detached chain is passed to {@code sink}, so subsequent lock-free + * readers observe an empty bucket while readers already on that chain can continue through its + * retained {@code next} links. This overload does not update size accounting. + * + *

The sink must not throw. If it does, the partial drain is not rolled back. + */ + public static void drain( + @Nonnull AtomicReferenceArray buckets, @Nonnull Consumer sink) { + drainCounting(buckets, sink); + } + + /** + * {@link #drain(AtomicReferenceArray, Consumer)} returning how many entries it handed to {@code + * sink}, so a {@link State} form can subtract exactly that from its {@link SizeManager} instead + * of zeroing. The count is free here: the sweep already visits every entry. + */ + private static int drainCounting( + @Nonnull AtomicReferenceArray buckets, @Nonnull Consumer sink) { + int removed = 0; + synchronized (getTableWriteLock(buckets)) { + for (int i = 0; i < buckets.length(); i++) { + TEntry head = buckets.get(i); + if (head == null) { + continue; + } + buckets.set(i, null); + for (TEntry e = head; e != null; e = e.next()) { + removed++; + sink.accept(e); + } + } + } + return removed; + } + + /** Context-passing variant of {@link #drain(AtomicReferenceArray, Consumer)}. Self-locking. */ + public static void drain( + @Nonnull AtomicReferenceArray buckets, + C context, + @Nonnull BiConsumer sink) { + drainCounting(buckets, context, sink); + } + + /** {@link #drainCounting(AtomicReferenceArray, Consumer)}, context-passing form. */ + private static int drainCounting( + @Nonnull AtomicReferenceArray buckets, + C context, + @Nonnull BiConsumer sink) { + int removed = 0; + synchronized (getTableWriteLock(buckets)) { + for (int i = 0; i < buckets.length(); i++) { + TEntry head = buckets.get(i); + if (head == null) { + continue; + } + buckets.set(i, null); + for (TEntry e = head; e != null; e = e.next()) { + removed++; + sink.accept(context, e); + } + } + } + return removed; + } + + /** + * {@link #drain(AtomicReferenceArray, Consumer)} plus the matching bookkeeping: empties {@code + * state} into {@code sink} and gives its {@link SizeManager} back exactly the slots the sweep + * freed. Draining without that leaves the cap permanently consumed, so the two belong in one call + * rather than as a pair the caller has to remember. + */ + public static void drain( + @Nonnull State state, @Nonnull Consumer sink) { + synchronized (getTableWriteLock(state)) { + state.sizeManager.release(drainCounting(state.buckets, sink)); + } + } + + /** Context-passing form of {@link #drain(State, Consumer)}. */ + public static void drain( + @Nonnull State state, + C context, + @Nonnull BiConsumer sink) { + synchronized (getTableWriteLock(state)) { + state.sizeManager.release(drainCounting(state.buckets, context, sink)); + } + } + + /** Nulls every bucket head. Self-locking: synchronizes on {@code buckets}. */ + public static void clear(@Nonnull AtomicReferenceArray buckets) { + synchronized (getTableWriteLock(buckets)) { + for (int i = 0; i < buckets.length(); i++) { + buckets.set(i, null); + } + } + } + + /** + * {@link #clear(AtomicReferenceArray)} returning how many entries it detached, so a {@link State} + * form can subtract exactly that rather than zeroing — see {@link SizeManager#release(int)} for + * why that distinction matters. Unlike the plain form this walks the chains, making it O(entries) + * rather than O(buckets); clear is a rare, whole-table operation, so the walk is affordable and + * keeping the count honest is worth more than the constant. + */ + private static int clearCounting(@Nonnull AtomicReferenceArray buckets) { + int removed = 0; + synchronized (getTableWriteLock(buckets)) { + for (int i = 0; i < buckets.length(); i++) { + Entry head = buckets.get(i); + if (head == null) { + continue; + } + buckets.set(i, null); + for (Entry e = head; e != null; e = e.next()) { + removed++; + } + } + } + return removed; + } + + /** + * {@link #clear(AtomicReferenceArray)} over a {@link State}: also resets its {@link SizeManager}. + */ + public static void clear(@Nonnull State state) { + synchronized (getTableWriteLock(state)) { + state.sizeManager.release(clearCounting(state.buckets)); + } + } + + public static void forEach( + @Nonnull AtomicReferenceArray buckets, @Nonnull Consumer consumer) { + for (int i = 0; i < buckets.length(); i++) { + for (TEntry curEntry = buckets.get(i); curEntry != null; curEntry = curEntry.next()) { + consumer.accept(curEntry); + } + } + } + + public static void forEach( + @Nonnull AtomicReferenceArray buckets, + C context, + @Nonnull BiConsumer consumer) { + for (int i = 0; i < buckets.length(); i++) { + for (TEntry curEntry = buckets.get(i); curEntry != null; curEntry = curEntry.next()) { + consumer.accept(context, curEntry); + } + } + } + + /** {@link #forEach(AtomicReferenceArray, Consumer)} over a {@link State}. */ + public static void forEach( + @Nonnull State state, @Nonnull Consumer consumer) { + forEach(state.buckets, consumer); + } + + /** {@link #forEach(AtomicReferenceArray, Object, BiConsumer)} over a {@link State}. */ + public static void forEach( + @Nonnull State state, + C context, + @Nonnull BiConsumer consumer) { + forEach(state.buckets, context, consumer); + } +} diff --git a/internal-api/src/test/groovy/datadog/trace/api/telemetry/LogCollectorTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/telemetry/LogCollectorTest.groovy deleted file mode 100644 index 4f798f5bf9f..00000000000 --- a/internal-api/src/test/groovy/datadog/trace/api/telemetry/LogCollectorTest.groovy +++ /dev/null @@ -1,66 +0,0 @@ -package datadog.trace.api.telemetry - -import datadog.trace.test.util.DDSpecification - -class LogCollectorTest extends DDSpecification { - - void "tracer time is set"() { - setup: - def logCollector = new LogCollector(1) - - when: - logCollector.addLogMessage("ERROR", "Message 1", null) - - then: - def log = logCollector.drain().toList().get(0) - def ts = log.timestamp - ts > 0L - // Check tracer time is not in millis - ts < 1706529524286L - } - - void "limit log messages in LogCollector"() { - setup: - def logCollector = new LogCollector(3) - - when: - logCollector.addLogMessage("ERROR", "Message 1", null) - logCollector.addLogMessage("ERROR", "Message 2", null) - logCollector.addLogMessage("ERROR", "Message 3", null) - logCollector.addLogMessage("ERROR", "Message 4", null) - - then: - logCollector.rawLogMessages.size() == 3 - } - - void "grouping messages in LogCollector"() { - when: - LogCollector.get().addLogMessage("ERROR", "First Message", null) - LogCollector.get().addLogMessage("ERROR", "Second Message", null) - LogCollector.get().addLogMessage("ERROR", "Third Message", null) - LogCollector.get().addLogMessage("ERROR", "Forth Message", null) - LogCollector.get().addLogMessage("ERROR", "Second Message", null) - LogCollector.get().addLogMessage("ERROR", "Third Message", null) - LogCollector.get().addLogMessage("ERROR", "Forth Message", null) - LogCollector.get().addLogMessage("ERROR", "Third Message", null) - LogCollector.get().addLogMessage("ERROR", "Forth Message", null) - LogCollector.get().addLogMessage("ERROR", "Forth Message", null) - - then: - def list = LogCollector.get().drain() - list.size() == 4 - listContains(list, 'ERROR', "First Message", null, 1) - listContains(list, 'ERROR', "Second Message", null, 2) - listContains(list, 'ERROR', "Third Message", null,3) - listContains(list, 'ERROR', "Forth Message", null, 4) - } - - boolean listContains(Collection list, String logLevel, String message, Throwable t, int count) { - for (final def logMsg in list) { - if (logMsg.logLevel == logLevel && logMsg.message == message && logMsg.throwable == t && logMsg.count == count) { - return true - } - } - return false - } -} diff --git a/internal-api/src/test/java/datadog/trace/api/telemetry/LogCollectorTest.java b/internal-api/src/test/java/datadog/trace/api/telemetry/LogCollectorTest.java new file mode 100644 index 00000000000..34e221b254a --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/telemetry/LogCollectorTest.java @@ -0,0 +1,211 @@ +package datadog.trace.api.telemetry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collection; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.Test; + +class LogCollectorTest { + + @Test + void setsTracerTime() { + LogCollector logCollector = new LogCollector(1); + long before = System.currentTimeMillis() / 1000; + + logCollector.addLogMessage("ERROR", "Message 1", null); + + long after = System.currentTimeMillis() / 1000; + LogCollector.RawLogMessage log = onlyLog(logCollector.drain()); + assertTrue(log.timestamp >= before); + assertTrue(log.timestamp <= after); + } + + @Test + void limitsLogMessages() { + LogCollector logCollector = new LogCollector(3); + + logCollector.addLogMessage("ERROR", "Message 1", null); + logCollector.addLogMessage("ERROR", "Message 2", null); + logCollector.addLogMessage("ERROR", "Message 3", null); + logCollector.addLogMessage("ERROR", "Message 4", null); + + assertEquals(3, logCollector.drain().size()); + } + + @Test + void groupsMessages() { + LogCollector logCollector = new LogCollector(10); + + logCollector.addLogMessage("ERROR", "First Message", null); + logCollector.addLogMessage("ERROR", "Second Message", null); + logCollector.addLogMessage("ERROR", "Third Message", null); + logCollector.addLogMessage("ERROR", "Fourth Message", null); + logCollector.addLogMessage("ERROR", "Second Message", null); + logCollector.addLogMessage("ERROR", "Third Message", null); + logCollector.addLogMessage("ERROR", "Fourth Message", null); + logCollector.addLogMessage("ERROR", "Third Message", null); + logCollector.addLogMessage("ERROR", "Fourth Message", null); + logCollector.addLogMessage("ERROR", "Fourth Message", null); + + Collection logs = logCollector.drain(); + assertEquals(4, logs.size()); + assertLog(logs, "First Message", 1); + assertLog(logs, "Second Message", 2); + assertLog(logs, "Third Message", 3); + assertLog(logs, "Fourth Message", 4); + } + + @Test + void dropsDuplicatesWhenFull() { + LogCollector logCollector = new LogCollector(1); + + logCollector.addLogMessage("ERROR", "Message", null); + logCollector.addLogMessage("ERROR", "Message", null); + + assertEquals(1, onlyLog(logCollector.drain()).count); + } + + @Test + void reusesCapacityAfterDrain() { + LogCollector logCollector = new LogCollector(1); + + logCollector.addLogMessage("ERROR", "First", null); + assertEquals("First", onlyLog(logCollector.drain()).message); + logCollector.addLogMessage("ERROR", "Second", null); + + assertEquals("Second", onlyLog(logCollector.drain()).message); + assertTrue(logCollector.drain().isEmpty()); + } + + @Test + void groupsEquivalentThrowablesAndKeepsFirstMetadata() { + LogCollector logCollector = new LogCollector(2); + Throwable first = throwableAtLine(10); + Throwable second = throwableAtLine(10); + + logCollector.addLogMessage("ERROR", "Message", first, "source:first"); + logCollector.addLogMessage("ERROR", "Message", second, "source:second"); + + LogCollector.RawLogMessage log = onlyLog(logCollector.drain()); + assertEquals(2, log.count); + assertSame(first, log.throwable); + assertEquals("source:first", log.tags); + } + + @Test + void keepsDifferentStackTracesSeparate() { + LogCollector logCollector = new LogCollector(2); + + logCollector.addLogMessage("ERROR", "Message", throwableAtLine(10)); + logCollector.addLogMessage("ERROR", "Message", throwableAtLine(20)); + + assertEquals(2, logCollector.drain().size()); + } + + @Test + void rawLogMessageEqualityMatchesDeduplication() { + LogCollector.RawLogMessage first = + new LogCollector.RawLogMessage("ERROR", "Message", throwableAtLine(10), "first", 1); + LogCollector.RawLogMessage equivalent = + new LogCollector.RawLogMessage("ERROR", "Message", throwableAtLine(10), "second", 2); + LogCollector.RawLogMessage different = + new LogCollector.RawLogMessage("ERROR", "Message", throwableAtLine(20), "first", 1); + + assertEquals(first, equivalent); + assertEquals(first.hashCode(), equivalent.hashCode()); + assertNotEquals(first, different); + } + + @Test + void countsConcurrentDuplicates() throws Exception { + int threadCount = 16; + int messagesPerThread = 1_000; + LogCollector logCollector = new LogCollector(2); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch start = new CountDownLatch(1); + Future[] futures = new Future[threadCount]; + try { + for (int i = 0; i < threadCount; i++) { + futures[i] = + executor.submit( + () -> { + start.await(); + for (int message = 0; message < messagesPerThread; message++) { + logCollector.addLogMessage("ERROR", "Message", null); + } + return null; + }); + } + start.countDown(); + for (Future future : futures) { + future.get(); + } + } finally { + executor.shutdownNow(); + } + + assertEquals(threadCount * messagesPerThread, onlyLog(logCollector.drain()).count); + } + + @Test + void capsConcurrentDistinctMessages() throws Exception { + int capacity = 3; + int threadCount = 16; + LogCollector logCollector = new LogCollector(capacity); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch start = new CountDownLatch(1); + Future[] futures = new Future[threadCount]; + try { + for (int i = 0; i < threadCount; i++) { + String message = "Message " + i; + futures[i] = + executor.submit( + () -> { + start.await(); + logCollector.addLogMessage("ERROR", message, null); + return null; + }); + } + start.countDown(); + for (Future future : futures) { + future.get(); + } + } finally { + executor.shutdownNow(); + } + + assertEquals(capacity, logCollector.drain().size()); + } + + private static Throwable throwableAtLine(int lineNumber) { + Throwable throwable = new IllegalStateException("ignored by deduplication"); + throwable.setStackTrace( + new StackTraceElement[] { + new StackTraceElement("Example", "run", "Example.java", lineNumber) + }); + return throwable; + } + + private static LogCollector.RawLogMessage onlyLog(Collection logs) { + assertEquals(1, logs.size()); + return logs.iterator().next(); + } + + private static void assertLog( + Collection logs, String message, int count) { + LogCollector.RawLogMessage log = + logs.stream() + .filter(candidate -> message.equals(candidate.message)) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing log message: " + message)); + assertEquals("ERROR", log.logLevel); + assertEquals(count, log.count); + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java new file mode 100644 index 00000000000..540eb036c2f --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java @@ -0,0 +1,506 @@ +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.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.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class ConcurrentHashtableD1Test { + + @Test + void getReturnsMappedEntry() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + StringEntry e = table.tryGetOrCreateOrNull("hello", k -> new StringEntry(k, 42)); + assertSame(e, table.get("hello")); + assertNull(table.get("world")); + } + + @Test + void getOrCreateOnMissBuildsEntry() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + int[] createCount = {0}; + StringEntry created = + table.tryGetOrCreateOrNull( + "a", + k -> { + createCount[0]++; + return new StringEntry(k, 1); + }); + assertNotNull(created); + assertEquals(1, table.size()); + assertEquals(1, createCount[0]); + assertSame(created, table.get("a")); + } + + @Test + void getOrCreateOnHitSkipsCreator() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + StringEntry seeded = table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 100)); + int[] createCount = {0}; + StringEntry got = + table.tryGetOrCreateOrNull( + "a", + k -> { + createCount[0]++; + return new StringEntry(k, 999); + }); + assertSame(seeded, got); + assertEquals(1, table.size()); + assertEquals(0, createCount[0]); + } + + @Test + void nullKeyIsSupported() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + StringEntry e = table.tryGetOrCreateOrNull(null, k -> new StringEntry(k, 0)); + assertNotNull(e); + assertSame(e, table.get(null)); + } + + @Test + void forEachVisitsAllEntries() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); + table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); + table.tryGetOrCreateOrNull("c", k -> new StringEntry(k, 3)); + Set seen = new HashSet<>(); + table.forEach(e -> seen.add(e.key)); + assertEquals(3, seen.size()); + assertTrue(seen.contains("a")); + assertTrue(seen.contains("b")); + assertTrue(seen.contains("c")); + } + + @Test + void forEachWithContextPassesContext() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + table.tryGetOrCreateOrNull("x", k -> new StringEntry(k, 10)); + table.tryGetOrCreateOrNull("y", k -> new StringEntry(k, 20)); + Set seen = new HashSet<>(); + table.forEach(seen, (ctx, e) -> ctx.add(e.key)); + assertEquals(2, seen.size()); + assertTrue(seen.contains("x")); + assertTrue(seen.contains("y")); + } + + @Test + void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + int threads = 16; + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch go = new CountDownLatch(1); + AtomicInteger createCount = new AtomicInteger(); + + Thread[] workers = new Thread[threads]; + for (int i = 0; i < threads; i++) { + workers[i] = + new Thread( + () -> { + ready.countDown(); + try { + go.await(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return; + } + table.tryGetOrCreateOrNull( + "shared", + k -> { + createCount.incrementAndGet(); + return new StringEntry(k, 1); + }); + }); + workers[i].start(); + } + ready.await(); + go.countDown(); + for (Thread w : workers) { + w.join(); + } + + assertEquals(1, table.size()); + assertEquals(1, createCount.get()); + } + + @Test + void chainedEntriesInSameBucketAreAllReachable() { + // All three keys share hash 0, so they land in the same bucket regardless of table size. + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(CollidingEntry.class, 8); + CollidingKey a = new CollidingKey("a", 0); + CollidingKey b = new CollidingKey("b", 0); // same bucket as a + CollidingKey c = new CollidingKey("c", 0); // same bucket + CollidingEntry ea = table.tryGetOrCreateOrNull(a, CollidingEntry::new); + CollidingEntry eb = table.tryGetOrCreateOrNull(b, CollidingEntry::new); + CollidingEntry ec = table.tryGetOrCreateOrNull(c, CollidingEntry::new); + assertEquals(3, table.size()); + assertSame(ea, table.get(a)); + assertSame(eb, table.get(b)); + assertSame(ec, table.get(c)); + assertNull(table.get(new CollidingKey("d", 0))); // same bucket, different label → miss + } + + @Test + void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException { + int threads = 16; + String[] keys = new String[threads]; + for (int i = 0; i < threads; i++) { + keys[i] = "key-" + i; + } + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, threads * 2); + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch go = new CountDownLatch(1); + + Thread[] workers = new Thread[threads]; + for (int i = 0; i < threads; i++) { + final String key = keys[i]; + workers[i] = + new Thread( + () -> { + ready.countDown(); + try { + go.await(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return; + } + table.tryGetOrCreateOrNull(key, k -> new StringEntry(k, 1)); + }); + workers[i].start(); + } + ready.await(); + go.countDown(); + for (Thread w : workers) { + w.join(); + } + + assertEquals(threads, table.size()); + for (String key : keys) { + assertNotNull(table.get(key)); + } + } + + @Test + void removeReturnsEntryAndShrinks() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + StringEntry a = table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); + table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); + assertSame(a, table.remove("a")); + assertEquals(1, table.size()); + assertNull(table.get("a")); + assertNotNull(table.get("b")); + } + + @Test + void removeAbsentKeyReturnsNull() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); + assertNull(table.remove("missing")); + assertEquals(1, table.size()); + } + + @Test + void removeHeadMiddleAndTailOfSameBucketChain() { + // All three keys share hash 0, so a, b, c land in the same bucket and form one chain. + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(CollidingEntry.class, 8); + CollidingKey a = new CollidingKey("a", 0); + CollidingKey b = new CollidingKey("b", 0); + CollidingKey c = new CollidingKey("c", 0); + table.tryGetOrCreateOrNull(a, CollidingEntry::new); + table.tryGetOrCreateOrNull(b, CollidingEntry::new); + table.tryGetOrCreateOrNull(c, CollidingEntry::new); + + // Remove a middle element; the other two stay reachable. + assertNotNull(table.remove(b)); + assertNull(table.get(b)); + assertNotNull(table.get(a)); + assertNotNull(table.get(c)); + assertEquals(2, table.size()); + + // Drain the rest. + assertNotNull(table.remove(c)); + assertNotNull(table.remove(a)); + assertEquals(0, table.size()); + assertNull(table.get(a)); + } + + @Test + void removeIfRemovesMatchingEntries() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 16); + for (int i = 0; i < 10; i++) { + final int v = i; + table.tryGetOrCreateOrNull("k" + i, k -> new StringEntry(k, v)); + } + boolean removed = table.removeIf(e -> e.value % 2 == 0); // removes values 0,2,4,6,8 + assertTrue(removed); + assertEquals(5, table.size()); + Set seen = new HashSet<>(); + table.forEach(e -> seen.add(e.key)); + assertEquals(5, seen.size()); + for (String key : seen) { + assertNotNull(table.get(key)); + } + } + + @Test + void removeIfReturnsFalseWhenNothingMatches() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); + assertFalse(table.removeIf(e -> false)); + assertEquals(1, table.size()); + } + + @Test + void clearEmptiesTableAndLeavesItUsable() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); + table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); + table.clear(); + assertEquals(0, table.size()); + assertNull(table.get("a")); + assertNull(table.get("b")); + StringEntry c = table.tryGetOrCreateOrNull("c", k -> new StringEntry(k, 3)); + assertSame(c, table.get("c")); + assertEquals(1, table.size()); + } + + @Test + void drainRemovesEveryEntryAndFeedsSink() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); + table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); + table.tryGetOrCreateOrNull("c", k -> new StringEntry(k, 3)); + + Set drained = new HashSet<>(); + int[] sum = {0}; + table.drain( + e -> { + drained.add(e.key); + sum[0] += e.value; + }); + + assertEquals(new HashSet<>(Arrays.asList("a", "b", "c")), drained); + assertEquals(6, sum[0]); + assertEquals(0, table.size()); + assertNull(table.get("a")); + // table remains usable after drain + StringEntry d = table.tryGetOrCreateOrNull("d", k -> new StringEntry(k, 4)); + assertSame(d, table.get("d")); + assertEquals(1, table.size()); + } + + @Test + void drainWithContextFeedsSink() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); + table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); + + Set drained = new HashSet<>(); + table.drain(drained, (ctx, e) -> ctx.add(e.key)); + + assertEquals(new HashSet<>(Arrays.asList("a", "b")), drained); + assertEquals(0, table.size()); + } + + @Test + void drainOnEmptyTableInvokesSinkZeroTimes() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + int[] count = {0}; + table.drain(e -> count[0]++); + assertEquals(0, count[0]); + assertEquals(0, table.size()); + } + + /** + * Exercises the volatile-{@code next} removal contract: while one key is repeatedly removed and + * re-added in a shared collision chain, the other keys in that chain must remain continuously + * visible to a concurrent lock-free reader. + */ + @Test + void concurrentReadsStaySafeWhileOneChainMemberChurns() throws InterruptedException { + // All keys share hash 0, putting every key in one bucket so removal splices a chain the + // reader is walking. + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(CollidingEntry.class, 16); + int n = 8; + CollidingKey[] keys = new CollidingKey[n]; + for (int i = 0; i < n; i++) { + keys[i] = new CollidingKey("k" + i, 0); + table.tryGetOrCreateOrNull(keys[i], CollidingEntry::new); + } + CollidingKey churn = keys[0]; // keys[1..] are stable and must never vanish + + AtomicBoolean stop = new AtomicBoolean(false); + AtomicInteger missed = new AtomicInteger(); + Thread reader = + new Thread( + () -> { + while (!stop.get()) { + for (int i = 1; i < n; i++) { + if (table.get(keys[i]) == null) { + missed.incrementAndGet(); + } + } + } + }); + reader.start(); + for (int r = 0; r < 100_000; r++) { + table.remove(churn); + table.tryGetOrCreateOrNull(churn, CollidingEntry::new); + } + stop.set(true); + reader.join(); + + assertEquals(0, missed.get(), "stable chain members must never be unreachable during removal"); + } + + @Test + void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + Maybe created = + table.tryGetOrCreateOrEvict("a", k -> new StringEntry(k, 1), e -> true); + assertTrue(created.isPresent()); + assertEquals(1, table.size()); + assertSame(created.getOrNull(), table.get("a")); + } + + @Test + void tryGetOrCreateOrEvictReturnsExistingEntryOnHitWithoutEvicting() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); + StringEntry a = table.tryGetOrCreateOrNull("a", k -> new StringEntry(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() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); + table.tryGetOrCreateOrNull("old", k -> new StringEntry(k, 1)); + assertTrue(table.isFull()); + + Maybe created = + table.tryGetOrCreateOrEvict("new", k -> new StringEntry(k, 2), e -> true); + assertTrue(created.isPresent()); + assertEquals("new", created.getOrNull().key); + assertEquals(1, table.size()); + assertNull(table.get("old")); + assertSame(created.getOrNull(), table.get("new")); + } + + @Test + void tryGetOrCreateOrEvictOrNullRefusesWhenFullAndNothingEvictable() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); + table.tryGetOrCreateOrNull("old", k -> new StringEntry(k, 1)); + + StringEntry result = + table.tryGetOrCreateOrEvictOrNull("new", k -> new StringEntry(k, 2), e -> false); + assertNull(result); + assertEquals(1, table.size()); + assertNotNull(table.get("old")); + assertNull(table.get("new")); + } + + @Test + void tryGetOrCreateOrEvictOrNullEvictionRunsBeforeThrowingCreator() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); + table.tryGetOrCreateOrNull("old", k -> new StringEntry(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")); + } + + private static final class StringEntry extends ConcurrentHashtable.D1.Entry { + final int value; + + StringEntry(String key, int value) { + super(key); + this.value = value; + } + } + + /** Key with a fixed hashCode to force deterministic bucket placement. */ + private static final class CollidingKey { + final String label; + final int fixedHash; + + CollidingKey(String label, int fixedHash) { + this.label = label; + this.fixedHash = fixedHash; + } + + @Override + public int hashCode() { + return fixedHash; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof CollidingKey)) { + return false; + } + CollidingKey that = (CollidingKey) o; + return fixedHash == that.fixedHash && label.equals(that.label); + } + } + + private static final class CollidingEntry extends ConcurrentHashtable.D1.Entry { + CollidingEntry(CollidingKey key) { + super(key); + } + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java new file mode 100644 index 00000000000..182cb4c4f25 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java @@ -0,0 +1,399 @@ +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.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.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class ConcurrentHashtableD2Test { + + @Test + void pairKeysParticipateInIdentity() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + PairEntry ab = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + PairEntry ac = table.tryGetOrCreateOrNull("a", 2, PairEntry::new); + PairEntry bb = table.tryGetOrCreateOrNull("b", 1, PairEntry::new); + assertEquals(3, table.size()); + assertSame(ab, table.get("a", 1)); + assertSame(ac, table.get("a", 2)); + assertSame(bb, table.get("b", 1)); + assertNull(table.get("a", 3)); + } + + @Test + void getOrCreateOnMissBuildsEntryViaCreator() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + int[] createCount = {0}; + PairEntry created = + table.tryGetOrCreateOrNull( + "a", + 1, + (k1, k2) -> { + createCount[0]++; + return new PairEntry(k1, k2); + }); + assertNotNull(created); + assertEquals("a", created.key1); + assertEquals(Integer.valueOf(1), created.key2); + assertEquals(1, table.size()); + assertEquals(1, createCount[0]); + assertSame(created, table.get("a", 1)); + } + + @Test + void getOrCreateOnHitSkipsCreator() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + PairEntry seeded = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + int[] createCount = {0}; + PairEntry got = + table.tryGetOrCreateOrNull( + "a", + 1, + (k1, k2) -> { + createCount[0]++; + return new PairEntry(k1, k2); + }); + assertSame(seeded, got); + assertEquals(1, table.size()); + assertEquals(0, createCount[0]); + } + + @Test + void forEachVisitsBothPairs() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + table.tryGetOrCreateOrNull("b", 2, PairEntry::new); + Set seen = new HashSet<>(); + table.forEach(e -> seen.add(e.key1 + ":" + e.key2)); + assertEquals(2, seen.size()); + assertTrue(seen.contains("a:1")); + assertTrue(seen.contains("b:2")); + } + + @Test + void forEachWithContextPassesContextToConsumer() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + table.tryGetOrCreateOrNull("b", 2, PairEntry::new); + Set seen = new HashSet<>(); + table.forEach(seen, (ctx, e) -> ctx.add(e.key1 + ":" + e.key2)); + assertEquals(2, seen.size()); + assertTrue(seen.contains("a:1")); + assertTrue(seen.contains("b:2")); + } + + @Test + void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + int threads = 16; + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch go = new CountDownLatch(1); + AtomicInteger createCount = new AtomicInteger(); + + Thread[] workers = new Thread[threads]; + for (int i = 0; i < threads; i++) { + workers[i] = + new Thread( + () -> { + ready.countDown(); + try { + go.await(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return; + } + table.tryGetOrCreateOrNull( + "shared", + 42, + (k1, k2) -> { + createCount.incrementAndGet(); + return new PairEntry(k1, k2); + }); + }); + workers[i].start(); + } + ready.await(); + go.countDown(); + for (Thread w : workers) { + w.join(); + } + + assertEquals(1, table.size()); + assertEquals(1, createCount.get()); + } + + @Test + void chainedEntriesInSameBucketAreAllReachable() { + // key2 = -31 * key1.hashCode() zeroes the combined hash, so all four land in bucket 0 + // regardless of table size. + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + PairEntry e1 = table.tryGetOrCreateOrNull("a", -31 * "a".hashCode(), PairEntry::new); + PairEntry e2 = table.tryGetOrCreateOrNull("b", -31 * "b".hashCode(), PairEntry::new); + PairEntry e3 = table.tryGetOrCreateOrNull("c", -31 * "c".hashCode(), PairEntry::new); + PairEntry e4 = table.tryGetOrCreateOrNull("d", -31 * "d".hashCode(), PairEntry::new); + assertEquals(4, table.size()); + assertSame(e1, table.get("a", -31 * "a".hashCode())); + assertSame(e2, table.get("b", -31 * "b".hashCode())); + assertSame(e3, table.get("c", -31 * "c".hashCode())); + assertSame(e4, table.get("d", -31 * "d".hashCode())); + assertNull(table.get("a", 3)); + } + + @Test + void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException { + int threads = 16; + String[] k1s = new String[threads]; + Integer[] k2s = new Integer[threads]; + for (int i = 0; i < threads; i++) { + k1s[i] = "key-" + i; + k2s[i] = i; + } + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, threads * 2); + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch go = new CountDownLatch(1); + + Thread[] workers = new Thread[threads]; + for (int i = 0; i < threads; i++) { + final String k1 = k1s[i]; + final Integer k2 = k2s[i]; + workers[i] = + new Thread( + () -> { + ready.countDown(); + try { + go.await(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return; + } + table.tryGetOrCreateOrNull(k1, k2, PairEntry::new); + }); + workers[i].start(); + } + ready.await(); + go.countDown(); + for (Thread w : workers) { + w.join(); + } + + assertEquals(threads, table.size()); + for (int i = 0; i < threads; i++) { + assertNotNull(table.get(k1s[i], k2s[i])); + } + } + + @Test + void removeReturnsEntryAndShrinks() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + PairEntry ab = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + table.tryGetOrCreateOrNull("a", 2, PairEntry::new); + assertSame(ab, table.remove("a", 1)); + assertEquals(1, table.size()); + assertNull(table.get("a", 1)); + assertNotNull(table.get("a", 2)); + } + + @Test + void removeAbsentKeyReturnsNull() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + assertNull(table.remove("a", 99)); + assertNull(table.remove("z", 1)); + assertEquals(1, table.size()); + } + + @Test + void removeMiddleOfSameBucketChainKeepsOthersReachable() { + // key2 = -31 * key1.hashCode() zeroes the combined hash, so all three land in one bucket + // chain regardless of table size. + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", -31 * "a".hashCode(), PairEntry::new); + PairEntry mid = table.tryGetOrCreateOrNull("b", -31 * "b".hashCode(), PairEntry::new); + table.tryGetOrCreateOrNull("c", -31 * "c".hashCode(), PairEntry::new); + + assertSame(mid, table.remove("b", -31 * "b".hashCode())); + assertNull(table.get("b", -31 * "b".hashCode())); + assertNotNull(table.get("a", -31 * "a".hashCode())); + assertNotNull(table.get("c", -31 * "c".hashCode())); + assertEquals(2, table.size()); + } + + @Test + void removeIfRemovesMatchingEntries() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 16); + for (int i = 0; i < 10; i++) { + table.tryGetOrCreateOrNull("k", i, PairEntry::new); + } + boolean removed = table.removeIf(e -> e.key2 % 2 == 0); // removes key2 0,2,4,6,8 + assertTrue(removed); + assertEquals(5, table.size()); + Set seen = new HashSet<>(); + table.forEach(e -> seen.add(e.key1 + ":" + e.key2)); + assertEquals(5, seen.size()); + } + + @Test + void removeIfReturnsFalseWhenNothingMatches() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + assertFalse(table.removeIf(e -> false)); + assertEquals(1, table.size()); + } + + @Test + void clearEmptiesTableAndLeavesItUsable() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + table.tryGetOrCreateOrNull("b", 2, PairEntry::new); + table.clear(); + assertEquals(0, table.size()); + assertNull(table.get("a", 1)); + PairEntry c = table.tryGetOrCreateOrNull("c", 3, PairEntry::new); + assertSame(c, table.get("c", 3)); + assertEquals(1, table.size()); + } + + @Test + void drainRemovesEveryEntryAndFeedsSink() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + table.tryGetOrCreateOrNull("a", 2, PairEntry::new); + table.tryGetOrCreateOrNull("b", 1, PairEntry::new); + + Set drained = new HashSet<>(); + table.drain(e -> drained.add(e.key1 + ":" + e.key2)); + + assertEquals(new HashSet<>(Arrays.asList("a:1", "a:2", "b:1")), drained); + assertEquals(0, table.size()); + assertNull(table.get("a", 1)); + PairEntry c = table.tryGetOrCreateOrNull("c", 3, PairEntry::new); + assertSame(c, table.get("c", 3)); + assertEquals(1, table.size()); + } + + @Test + void drainWithContextFeedsSink() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + table.tryGetOrCreateOrNull("b", 2, PairEntry::new); + + Set drained = new HashSet<>(); + table.drain(drained, (ctx, e) -> ctx.add(e.key1 + ":" + e.key2)); + + assertEquals(new HashSet<>(Arrays.asList("a:1", "b:2")), drained); + assertEquals(0, table.size()); + } + + @Test + void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + Maybe created = table.tryGetOrCreateOrEvict("a", 1, PairEntry::new, e -> true); + assertTrue(created.isPresent()); + assertEquals(1, table.size()); + assertSame(created.getOrNull(), table.get("a", 1)); + } + + @Test + void tryGetOrCreateOrEvictReturnsExistingEntryOnHitWithoutEvicting() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); + PairEntry a = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + 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() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); + table.tryGetOrCreateOrNull("old", 1, PairEntry::new); + assertTrue(table.isFull()); + + Maybe created = table.tryGetOrCreateOrEvict("new", 2, PairEntry::new, e -> true); + assertTrue(created.isPresent()); + assertEquals("new", created.getOrNull().key1); + assertEquals(1, table.size()); + assertNull(table.get("old", 1)); + assertSame(created.getOrNull(), table.get("new", 2)); + } + + @Test + void tryGetOrCreateOrEvictOrNullRefusesWhenFullAndNothingEvictable() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); + table.tryGetOrCreateOrNull("old", 1, PairEntry::new); + + PairEntry result = table.tryGetOrCreateOrEvictOrNull("new", 2, PairEntry::new, e -> false); + assertNull(result); + assertEquals(1, table.size()); + assertNotNull(table.get("old", 1)); + assertNull(table.get("new", 2)); + } + + @Test + void tryGetOrCreateOrEvictOrNullEvictionRunsBeforeThrowingCreator() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); + table.tryGetOrCreateOrNull("old", 1, PairEntry::new); + + 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)); + } + + private static final class PairEntry extends ConcurrentHashtable.D2.Entry { + PairEntry(String key1, Integer key2) { + super(key1, key2); + } + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java new file mode 100644 index 00000000000..24c581b2380 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java @@ -0,0 +1,431 @@ +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.test.util.PollingConditions; +import java.util.function.Predicate; +import org.junit.jupiter.api.Test; + +/** + * Exercises {@link ConcurrentHashtable.SizeManager} and {@link ConcurrentHashtable.State} against a + * {@link ConcurrentHashtable.State}, the same shape a custom table driving the static building + * blocks would use. {@link ConcurrentHashtableD1Test} and {@link ConcurrentHashtableD2Test} cover + * the eviction-aware {@code tryGetOrCreateOrEvict} methods built on top of this. + */ +class ConcurrentHashtableSizeManagerTest { + + @Test + void tryReserveSucceedsUnderCapacityAndFailsWhenFull() { + ConcurrentHashtable.SizeManager sizeManager = new ConcurrentHashtable.SizeManager(2); + assertEquals(0, sizeManager.estimateSize()); + assertFalse(sizeManager.isFull()); + + assertTrue(sizeManager.tryReserve()); + assertEquals(1, sizeManager.estimateSize()); + assertFalse(sizeManager.isFull()); + + assertTrue(sizeManager.tryReserve()); + assertEquals(2, sizeManager.estimateSize()); + assertTrue(sizeManager.isFull()); + + assertFalse(sizeManager.tryReserve()); + assertEquals(2, sizeManager.estimateSize()); + } + + @Test + void tryReserveOrEvictReservesDirectlyWhenUnderCapacity() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createBounded(TestEntry.class, 2); + + boolean reserved = tryReserveOrEvict(state, e -> true); + assertTrue(reserved); + assertEquals(1, state.sizeManager.estimateSize()); + assertNull(state.buckets.get(0)); // nothing was evicted + } + + @Test + void tryReserveOrEvictEvictsWhenFullAndSomethingMatches() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createBounded(TestEntry.class, 1); + TestEntry existing = insertAt(state, 0, "existing"); + assertTrue(state.sizeManager.tryReserve()); + assertTrue(state.sizeManager.isFull()); + + boolean reserved = tryReserveOrEvict(state, e -> true); + assertTrue(reserved); + assertEquals(1, state.sizeManager.estimateSize()); // one evicted, one reserved: net unchanged + assertNull(state.buckets.get(0)); // existing was unlinked + } + + @Test + void tryReserveOrEvictFailsAndLeavesTableUntouchedWhenNothingEvictable() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createBounded(TestEntry.class, 1); + TestEntry existing = insertAt(state, 0, "existing"); + assertTrue(state.sizeManager.tryReserve()); + + boolean reserved = tryReserveOrEvict(state, e -> false); + assertFalse(reserved); + assertEquals(1, state.sizeManager.estimateSize()); + assertSame(existing, state.buckets.get(0)); + } + + @Test + void insertReservedSplicesWithoutTouchingTheCountAfterATryReserve() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createBounded(TestEntry.class, 2); + assertTrue(state.sizeManager.tryReserve()); + assertEquals(1, state.sizeManager.estimateSize()); + + TestEntry entry = new TestEntry(0, "reserved"); + synchronized (ConcurrentHashtable.getTableWriteLock(state)) { + ConcurrentHashtable.insertReserved(state, entry.keyHash, entry); + } + + assertSame(entry, state.buckets.get(0)); + // Count reflects only the earlier tryReserve() -- insertReserved must not increment again. + assertEquals(1, state.sizeManager.estimateSize()); + } + + @Test + void evictOneReturnsNullAndLeavesCountUnchangedWhenNothingMatches() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createBounded(TestEntry.class, 4); + insertAt(state, 0, "a"); + state.sizeManager.increment(); + + TestEntry evicted = evictOne(state, e -> false); + assertNull(evicted); + assertEquals(1, state.sizeManager.estimateSize()); + } + + @Test + void evictOneUnlinksMatchAndDecrementsCount() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createBounded(TestEntry.class, 4); + TestEntry a = insertAt(state, 0, "a"); + TestEntry b = insertAt(state, 1, "b"); + state.sizeManager.increment(); + state.sizeManager.increment(); + + TestEntry evicted = evictOne(state, e -> e.label.equals("a")); + assertSame(a, evicted); + assertNull(state.buckets.get(0)); + assertSame(b, state.buckets.get(1)); // untouched + assertEquals(1, state.sizeManager.estimateSize()); + } + + /** + * Verifies the cursor-resume contract from {@link ConcurrentHashtable.SizeManager#evictOne}: each + * scan resumes where the previous eviction left off, so among several equally-matching candidates + * the one nearest (forward from the cursor, wrapping) is picked first -- not always the lowest + * bucket index. + */ + @Test + void evictOneResumesFromLastEvictedBucketAndWrapsAround() { + // Bucket-array length 4: keyHash i lands in bucket i. + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createBounded(TestEntry.class, 4); + TestEntry e0 = insertAt(state, 0, "e0"); + insertAt(state, 2, "e2"); + TestEntry e3 = insertAt(state, 3, "e3"); + state.sizeManager.increment(); + state.sizeManager.increment(); + state.sizeManager.increment(); + + // First eviction: scan starts at cursor 0, finds bucket 2 first among evictable entries + // (only e2 matches here) -- sets the cursor to 2. + TestEntry firstEvicted = evictOne(state, e -> e.label.equals("e2")); + assertEquals("e2", firstEvicted.label); + + // Second eviction: both e0 (bucket 0) and e3 (bucket 3) match. Scanning resumes at the + // cursor (2) and goes forward before wrapping, so bucket 3 (e3) is found before bucket 0. + TestEntry secondEvicted = evictOne(state, e -> true); + assertSame(e3, secondEvicted); + assertSame(e0, state.buckets.get(0)); // e0 not yet touched + + // Third eviction: only e0 remains. The cursor is now past bucket 3, so the scan must wrap + // around to bucket 0 to find it. + TestEntry thirdEvicted = evictOne(state, e -> true); + assertSame(e0, thirdEvicted); + assertEquals(0, state.sizeManager.estimateSize()); + } + + @Test + void evictAllRemovesEveryMatchAndReturnsCount() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createBounded(TestEntry.class, 8); + for (int i = 0; i < 6; i++) { + insertAt(state, i, "e" + i); + state.sizeManager.increment(); + } + // Evict everything except bucket 1 and bucket 4. + int count = evictAll(state, e -> !e.label.equals("e1") && !e.label.equals("e4")); + + assertEquals(4, count); + assertEquals(2, state.sizeManager.estimateSize()); + assertNotNullLabel(state, 1, "e1"); + assertNotNullLabel(state, 4, "e4"); + for (int i : new int[] {0, 2, 3, 5}) { + assertNull(state.buckets.get(i)); + } + } + + @Test + void evictAllResetsCursorSoSubsequentEvictOneScansFromBucketZero() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createBounded(TestEntry.class, 4); + insertAt(state, 2, "a"); + state.sizeManager.increment(); + // Advance the cursor away from 0 via a successful eviction at bucket 2. + evictOne(state, e -> true); + + // A full pass that removes nothing still resets the scan position (per evictAll's contract). + int count = evictAll(state, e -> false); + assertEquals(0, count); + + TestEntry e0 = insertAt(state, 0, "e0"); + TestEntry e3 = insertAt(state, 3, "e3"); + state.sizeManager.increment(); + state.sizeManager.increment(); + + // With the cursor reset to 0, the forward scan reaches bucket 0 before bucket 3. + TestEntry evicted = evictOne(state, e -> true); + assertSame(e0, evicted); + assertSame(e3, state.buckets.get(3)); + } + + @Test + void releaseGivesBackRemovedSlotsAndRestartsScan() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createBounded(TestEntry.class, 4); + insertAt(state, 2, "a"); + state.sizeManager.increment(); + evictOne(state, e -> true); // advances the cursor to 2, count back to 0 + state.sizeManager.increment(); // pretend a fresh entry was inserted + + // One entry is live and counted; releasing that one slot brings the count back to zero and + // restarts the scan -- unlike a blanket zeroing, this only gives back what was removed. + state.sizeManager.release(1); + assertEquals(0, state.sizeManager.estimateSize()); + + TestEntry e0 = insertAt(state, 0, "e0"); + TestEntry e3 = insertAt(state, 3, "e3"); + state.sizeManager.increment(); + state.sizeManager.increment(); + TestEntry evicted = evictOne(state, e -> true); + assertSame(e0, evicted); // scan restarted from bucket 0, per release() + assertSame(e3, state.buckets.get(3)); + } + + @Test + void stateCreateCappedBundlesBucketsAndSizeManager() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createBounded(TestEntry.class, 3); + assertEquals(0, state.sizeManager.estimateSize()); + assertEquals(3, state.sizeManager.capacity()); + assertTrue(state.buckets.length() >= 3); + } + + @Test + void stateLevelTryReserveOrEvictAndEvictOneAndEvictAllDelegateToSizeManager() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createBounded(TestEntry.class, 1); + synchronized (ConcurrentHashtable.getWriteLockAt(state, 0)) { + ConcurrentHashtable.insertHeadEntryAt(state, 0, new TestEntry(0, "a")); + } + state.sizeManager.increment(); + assertTrue(ConcurrentHashtable.isFull(state)); + + // Table is full: tryReserveOrEvict evicts "a" and reserves the freed slot for the caller, who + // is now responsible for splicing in the entry that occupies it -- via insertReserved, since + // the reservation already happened and a plain insertHeadEntryAt/increment would double-count. + // Both steps go in ONE critical section: tryReserveOrEvict is self-locking, so on its own it + // leaves a window where a drain/clear could reset the count out from under the reservation. + synchronized (ConcurrentHashtable.getTableWriteLock(state)) { + boolean reserved = ConcurrentHashtable.tryReserveOrEvict(state, e -> true); + assertTrue(reserved); + assertEquals(1, ConcurrentHashtable.estimateSize(state)); + assertNull(state.buckets.get(0)); // "a" was evicted; the reserved slot has no entry yet + ConcurrentHashtable.insertReserved(state, 0, new TestEntry(0, "reserved")); + } + + int evicted = ConcurrentHashtable.evictAll(state, e -> true); + assertEquals(1, evicted); + assertEquals(0, ConcurrentHashtable.estimateSize(state)); + assertFalse(ConcurrentHashtable.isFull(state)); + + synchronized (ConcurrentHashtable.getWriteLockAt(state, 0)) { + ConcurrentHashtable.insertHeadEntryAt(state, 0, new TestEntry(0, "b")); + } + state.sizeManager.increment(); + TestEntry viaEvictOne = ConcurrentHashtable.evictOne(state, e -> e.label.equals("b")); + assertNotNull(viaEvictOne); + assertEquals("b", viaEvictOne.label); + assertEquals(0, ConcurrentHashtable.estimateSize(state)); + } + + /** + * Holding the table lock across {@code tryReserveOrEvict} + {@code insertReserved} still keeps a + * concurrent {@link ConcurrentHashtable#clear(ConcurrentHashtable.State)} out of the gap -- the + * belt-and-braces version of the protocol. {@link + * #reservationSurvivesAClearLandingBetweenReserveAndInsert()} covers the case that matters more + * now: the pair does not actually need one critical section. + */ + @Test + void clearCannotInterleaveBetweenReservationAndInsert() throws InterruptedException { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createBounded(TestEntry.class, 1); + insertAt(state, 0, "a"); + state.sizeManager.increment(); + assertTrue(ConcurrentHashtable.isFull(state)); + + Thread clearer = new Thread(() -> ConcurrentHashtable.clear(state), "clearer"); + synchronized (ConcurrentHashtable.getTableWriteLock(state)) { + clearer.start(); + // Wait until the clear is definitely queued on the monitor we hold, so the interleaving under + // test is the one actually attempted rather than one the scheduler happened to avoid. + new PollingConditions() + .eventually(() -> assertEquals(Thread.State.BLOCKED, clearer.getState())); + + assertTrue(ConcurrentHashtable.tryReserveOrEvict(state, e -> true)); + ConcurrentHashtable.insertReserved(state, 0, new TestEntry(0, "reserved")); + assertEquals(1, ConcurrentHashtable.estimateSize(state)); + } + clearer.join(); + + // The clear ran strictly after the pair, so the table is exactly post-clear: no entry, no + // count, and -- the point -- the two agree. + assertEquals(0, ConcurrentHashtable.estimateSize(state)); + assertNull(state.buckets.get(0)); + assertFalse(ConcurrentHashtable.isFull(state)); + } + + /** + * A clear landing squarely between the reservation and the insert must not void the reservation. + * It cannot, because {@link ConcurrentHashtable.SizeManager#release(int)} subtracts what the + * sweep removed instead of zeroing the count -- so the claim taken before the clear is still a + * claim after it, and the entry the caller then links is accounted for. + * + *

Zeroing would leave the count one below reality here, permanently: eviction decrements too, + * so nothing later repairs it, and a capped table admits one extra entry from then on. + */ + @Test + void reservationSurvivesAClearLandingBetweenReserveAndInsert() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createBounded(TestEntry.class, 2); + insertAt(state, 0, "a"); + state.sizeManager.increment(); + + // Reserve, with no lock held across what follows. + assertTrue(ConcurrentHashtable.tryReserveOrEvict(state, e -> false)); + assertEquals(2, ConcurrentHashtable.estimateSize(state)); // "a" plus our reservation + + // The sweep lands in the gap, removing the one entry that exists. Our slot is not its to give + // back, so the count drops by exactly one. + ConcurrentHashtable.clear(state); + assertEquals(1, ConcurrentHashtable.estimateSize(state)); + + // The reservation is still good, and filling it leaves the count matching the entries present. + synchronized (ConcurrentHashtable.getWriteLockAt(state, 0)) { + ConcurrentHashtable.insertReserved(state, 0, new TestEntry(0, "reserved")); + } + assertEquals(1, ConcurrentHashtable.estimateSize(state)); + assertNotNullLabel(state, 0, "reserved"); + assertFalse(ConcurrentHashtable.isFull(state)); + } + + /** + * Two reservers racing at the cap cannot both get through: {@code tryReserve} claims first and + * refunds on overshoot, so the count is never left above {@link + * ConcurrentHashtable.SizeManager#capacity()} once both have finished -- and it achieves that + * without a lock. + */ + @Test + void concurrentReserversCannotBothPassTheCap() throws InterruptedException { + for (int attempt = 0; attempt < 200; attempt++) { + ConcurrentHashtable.SizeManager sizeManager = new ConcurrentHashtable.SizeManager(1); + java.util.concurrent.atomic.AtomicInteger granted = + new java.util.concurrent.atomic.AtomicInteger(); + java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1); + Runnable reserve = + () -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + if (sizeManager.tryReserve()) { + granted.incrementAndGet(); + } + }; + Thread t1 = new Thread(reserve, "reserve-1"); + Thread t2 = new Thread(reserve, "reserve-2"); + t1.start(); + t2.start(); + start.countDown(); + t1.join(); + t2.join(); + + assertEquals(1, granted.get(), "exactly one reserver should win a capacity of 1"); + assertEquals(1, sizeManager.estimateSize()); + } + } + + private static void assertNotNullLabel( + ConcurrentHashtable.State state, int index, String label) { + TestEntry e = state.buckets.get(index); + assertNotNull(e); + assertEquals(label, e.label); + } + + /** Inserts a fresh entry at the given bucket index. Bucket-array length must exceed index. */ + private static TestEntry insertAt( + ConcurrentHashtable.State state, int index, String label) { + TestEntry entry = new TestEntry(index, label); + synchronized (ConcurrentHashtable.getWriteLockAt(state, index)) { + ConcurrentHashtable.insertHeadEntryAt(state, index, entry); + } + return entry; + } + + /** {@code sizeManager.tryReserveOrEvict}, taking the write lock {@code @GuardedBy} requires. */ + private static boolean tryReserveOrEvict( + ConcurrentHashtable.State state, Predicate evictable) { + synchronized (ConcurrentHashtable.getTableWriteLock(state)) { + return state.sizeManager.tryReserveOrEvict(state.buckets, evictable); + } + } + + /** {@code sizeManager.evictOne}, taking the write lock {@code @GuardedBy} requires. */ + private static TestEntry evictOne( + ConcurrentHashtable.State state, Predicate evictable) { + synchronized (ConcurrentHashtable.getTableWriteLock(state)) { + return state.sizeManager.evictOne(state.buckets, evictable); + } + } + + /** {@code sizeManager.evictAll}, taking the write lock {@code @GuardedBy} requires. */ + private static int evictAll( + ConcurrentHashtable.State state, Predicate evictable) { + synchronized (ConcurrentHashtable.getTableWriteLock(state)) { + return state.sizeManager.evictAll(state.buckets, evictable); + } + } + + /** Entry with a caller-controlled {@code keyHash} so tests can place it in an exact bucket. */ + private static final class TestEntry extends ConcurrentHashtable.Entry { + final String label; + + TestEntry(long keyHash, String label) { + super(keyHash); + this.label = label; + } + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java new file mode 100644 index 00000000000..8191e396e14 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java @@ -0,0 +1,429 @@ +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.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 static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReferenceArray; +import org.junit.jupiter.api.Test; + +/** + * Exercises the {@code static} building blocks that {@link ConcurrentHashtable} exposes for the + * caller-owned-array path — the custom-table API used when {@link ConcurrentHashtable.D1}/{@link + * ConcurrentHashtable.D2}'s object-key constraints don't fit (primitive keys, higher arity, extra + * per-entry fields). {@link IntTable} below is a minimal hand-written table with a primitive {@code + * int} key, driving the same lock-free-read / locked-write recipe the class Javadoc documents. + */ +class ConcurrentHashtableStaticsTest { + + @Test + void sizeForRoundsUpToPowerOfTwo() { + assertEquals(1, ConcurrentHashtable.sizeFor(1)); + assertEquals(8, ConcurrentHashtable.sizeFor(5)); + assertEquals(8, ConcurrentHashtable.sizeFor(8)); + assertEquals(16, ConcurrentHashtable.sizeFor(9)); + } + + @Test + void createFixedBucketsAllocatesPowerOfTwoSpine() { + AtomicReferenceArray buckets = + ConcurrentHashtable.createFixedBuckets(IntEntry.class, 10); + assertEquals(16, buckets.length()); + assertNull(buckets.get(0)); + } + + @Test + void getWriteLockIsStableAndNonNull() { + AtomicReferenceArray buckets = + ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); + Object lock = ConcurrentHashtable.getWriteLock(buckets, 3L); + assertNotNull(lock); + assertSame(lock, ConcurrentHashtable.getWriteLock(buckets, 3L)); + // The keyHash form is defined as the index form under bucketIndex. + assertSame( + lock, + ConcurrentHashtable.getWriteLockAt(buckets, ConcurrentHashtable.bucketIndex(buckets, 3L))); + } + + /** + * The three accessors name three scopes, but a single-lock table answers all of them with one + * monitor. Asserting that pins today's granularity as a deliberate choice rather than an + * accident: if it ever changes, this is the test that says so, and callers that asked for the + * scope they actually mutate keep working. + */ + @Test + void allWriteLockScopesResolveToOneMonitorToday() { + AtomicReferenceArray buckets = + ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); + Object table = ConcurrentHashtable.getTableWriteLock(buckets); + assertNotNull(table); + for (int index = 0; index < buckets.length(); index++) { + assertSame(table, ConcurrentHashtable.getWriteLockAt(buckets, index)); + assertSame(table, ConcurrentHashtable.getWriteLock(buckets, index)); + } + } + + @Test + void bucketIndexMasksToArrayLength() { + AtomicReferenceArray buckets = + ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); // length 8, mask 7 + assertEquals(0, ConcurrentHashtable.bucketIndex(buckets, 8L)); + assertEquals(1, ConcurrentHashtable.bucketIndex(buckets, 9L)); + assertEquals(7, ConcurrentHashtable.bucketIndex(buckets, 7L)); + } + + @Test + void insertGetAndRemoveViaStatics() { + IntTable table = new IntTable(8); + IntEntry a = table.getOrCreate(1, 100); + IntEntry b = table.getOrCreate(2, 200); + assertEquals(2, table.size.get()); + assertSame(a, table.get(1)); + assertSame(b, table.get(2)); + assertNull(table.get(3)); + + // getOrCreate on a hit returns the existing entry, no new insert. + assertSame(a, table.getOrCreate(1, 999)); + assertEquals(2, table.size.get()); + + assertSame(a, table.remove(1)); + assertNull(table.get(1)); + assertNull(table.remove(1)); // already gone + assertEquals(1, table.size.get()); + } + + @Test + void insertHeadEntryForPlacesInBucketMaskedFromKeyHash() { + AtomicReferenceArray buckets = + ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); // mask 7 + IntEntry e = new IntEntry(9, 1); // keyHash 9 → bucket 1 + synchronized (ConcurrentHashtable.getWriteLock(buckets, e.keyHash)) { + ConcurrentHashtable.insertHeadEntryFor(buckets, e.keyHash, e); + } + assertSame(e, ConcurrentHashtable.bucketFor(buckets, 9L)); // masks keyHash to the bucket index + assertSame( + e, ConcurrentHashtable.bucketAt(buckets, 1)); // same slot, addressed directly by index + assertNull(buckets.get(0)); + } + + @Test + void unlinkRemovesHeadMiddleAndTailOfChain() { + // Capacity 1 → mask 0 → every key lands in bucket 0, forming one chain. + IntTable table = new IntTable(1); + IntEntry a = table.getOrCreate(1, 1); + IntEntry b = table.getOrCreate(2, 2); + IntEntry c = table.getOrCreate(3, 3); + assertEquals(3, table.size.get()); + + // Remove the middle: head and tail stay reachable. + assertSame(b, table.remove(2)); + assertNull(table.get(2)); + assertSame(a, table.get(1)); + assertSame(c, table.get(3)); + assertEquals(2, table.size.get()); + + // Remove the head, then the last remaining. + assertSame(c, table.remove(3)); + assertSame(a, table.remove(1)); + assertEquals(0, table.size.get()); + assertNull(table.get(1)); + } + + @Test + void staticRemoveIfRemovesMatchingAndDecrementsSize() { + IntTable table = new IntTable(16); + for (int i = 0; i < 10; i++) { + table.getOrCreate(i, i); + } + boolean removed = + ConcurrentHashtable.removeIf(table.buckets, table.size, e -> e.value % 2 == 0); + assertTrue(removed); + assertEquals(5, table.size.get()); + for (int i = 0; i < 10; i++) { + if (i % 2 == 0) { + assertNull(table.get(i)); + } else { + assertNotNull(table.get(i)); + } + } + } + + @Test + void staticRemoveIfReturnsFalseWhenNothingMatches() { + IntTable table = new IntTable(8); + table.getOrCreate(1, 1); + assertFalse(ConcurrentHashtable.removeIf(table.buckets, table.size, e -> false)); + assertEquals(1, table.size.get()); + } + + @Test + void staticDrainRemovesEveryEntryAndFeedsSink() { + IntTable table = new IntTable(8); + table.getOrCreate(1, 10); + table.getOrCreate(2, 20); + table.getOrCreate(3, 30); + + Set keys = new HashSet<>(); + int[] sum = {0}; + ConcurrentHashtable.drain( + table.buckets, + e -> { + keys.add(e.key); + sum[0] += e.value; + }); + + assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2, 3)), keys); + assertEquals(60, sum[0]); + // drain does not touch size accounting on the static path — the caller resets it. + for (int i = 1; i <= 3; i++) { + assertNull(table.get(i)); + } + } + + @Test + void staticDrainWithContextFeedsSink() { + IntTable table = new IntTable(8); + table.getOrCreate(1, 10); + table.getOrCreate(2, 20); + + Set keys = new HashSet<>(); + ConcurrentHashtable.drain(table.buckets, keys, (ctx, e) -> ctx.add(e.key)); + + assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2)), keys); + assertNull(table.get(1)); + } + + @Test + void staticClearEmptiesEveryBucket() { + IntTable table = new IntTable(8); + table.getOrCreate(1, 1); + table.getOrCreate(2, 2); + ConcurrentHashtable.clear(table.buckets); + assertNull(table.get(1)); + assertNull(table.get(2)); + for (int i = 0; i < table.buckets.length(); i++) { + assertNull(table.buckets.get(i)); + } + } + + @Test + void staticForEachVisitsEveryEntry() { + IntTable table = new IntTable(8); + table.getOrCreate(1, 1); + table.getOrCreate(2, 2); + table.getOrCreate(3, 3); + + Set seen = new HashSet<>(); + ConcurrentHashtable.forEach(table.buckets, e -> seen.add(e.key)); + assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2, 3)), seen); + + Set seenCtx = new HashSet<>(); + ConcurrentHashtable.forEach(table.buckets, seenCtx, (ctx, e) -> ctx.add(e.key)); + assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2, 3)), seenCtx); + } + + @Test + void insertHeadEntryWithoutLockTripsAssertion() { + assumeTrue(assertionsEnabled(), "assert-guard test requires -ea"); + AtomicReferenceArray buckets = + ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); + assertThrows( + AssertionError.class, + () -> ConcurrentHashtable.insertHeadEntryAt(buckets, 0, new IntEntry(1, 1))); + } + + @Test + void unlinkWithoutLockTripsAssertion() { + assumeTrue(assertionsEnabled(), "assert-guard test requires -ea"); + AtomicReferenceArray buckets = + ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); + IntEntry e = new IntEntry(1, 1); + synchronized (ConcurrentHashtable.getWriteLockAt(buckets, 0)) { + ConcurrentHashtable.insertHeadEntryAt(buckets, 0, e); + } + assertThrows(AssertionError.class, () -> ConcurrentHashtable.unlink(buckets, 0, null, e)); + } + + @Test + void concurrentGetOrCreateViaStaticsProducesExactlyOneEntry() throws InterruptedException { + IntTable table = new IntTable(8); + int threads = 16; + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch go = new CountDownLatch(1); + AtomicInteger createCount = new AtomicInteger(); + + Thread[] workers = new Thread[threads]; + for (int i = 0; i < threads; i++) { + workers[i] = + new Thread( + () -> { + ready.countDown(); + try { + go.await(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return; + } + table.getOrCreateCounting(7, createCount); + }); + workers[i].start(); + } + ready.await(); + go.countDown(); + for (Thread w : workers) { + w.join(); + } + + assertEquals(1, table.size.get()); + assertEquals(1, createCount.get()); + } + + @Test + void concurrentReadsStaySafeWhileOneChainMemberChurnsViaStatics() throws InterruptedException { + // Capacity 1 puts every key in one bucket so unlink splices a chain the reader is walking. + IntTable table = new IntTable(1); + int n = 8; + for (int i = 0; i < n; i++) { + table.getOrCreate(i, i); + } + int churn = 0; // keys 1..n-1 are stable and must never vanish + + AtomicBoolean stop = new AtomicBoolean(false); + AtomicInteger missed = new AtomicInteger(); + Thread reader = + new Thread( + () -> { + while (!stop.get()) { + for (int i = 1; i < n; i++) { + if (table.get(i) == null) { + missed.incrementAndGet(); + } + } + } + }); + reader.start(); + for (int r = 0; r < 100_000; r++) { + table.remove(churn); + table.getOrCreate(churn, churn); + } + stop.set(true); + reader.join(); + + assertEquals(0, missed.get(), "stable chain members must never be unreachable during removal"); + } + + private static boolean assertionsEnabled() { + boolean enabled = false; + assert enabled = true; + return enabled; + } + + /** Primitive-{@code int}-key entry: no boxing, keyHash is the key itself. */ + private static final class IntEntry extends ConcurrentHashtable.Entry { + final int key; + final int value; + + IntEntry(int key, int value) { + super(key); + this.key = key; + this.value = value; + } + + boolean matches(int key) { + return this.key == key; + } + } + + /** + * Minimal hand-written table over a caller-owned {@link AtomicReferenceArray}, following the + * documented recipe: lock-free pre-check, then re-check + mutate under {@code + * getWriteLock(buckets, keyHash)}. + */ + private static final class IntTable { + final AtomicReferenceArray buckets; + final AtomicInteger size = new AtomicInteger(); + + IntTable(int capacity) { + this.buckets = ConcurrentHashtable.createFixedBuckets(IntEntry.class, capacity); + } + + IntEntry get(int key) { + for (IntEntry e = ConcurrentHashtable.bucketFor(buckets, (long) key); + e != null; + e = e.next()) { + if (e.matches(key)) { + return e; + } + } + return null; + } + + IntEntry getOrCreate(int key, int value) { + int index = ConcurrentHashtable.bucketIndex(buckets, key); + for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { + if (e.matches(key)) { + return e; + } + } + synchronized (ConcurrentHashtable.getWriteLockAt(buckets, index)) { + for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { + if (e.matches(key)) { + return e; + } + } + IntEntry created = new IntEntry(key, value); + ConcurrentHashtable.insertHeadEntryAt(buckets, index, created); + size.incrementAndGet(); + return created; + } + } + + /** {@link #getOrCreate} variant that counts real creations, for the exactly-once race test. */ + IntEntry getOrCreateCounting(int key, AtomicInteger createCount) { + int index = ConcurrentHashtable.bucketIndex(buckets, key); + for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { + if (e.matches(key)) { + return e; + } + } + synchronized (ConcurrentHashtable.getWriteLockAt(buckets, index)) { + for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { + if (e.matches(key)) { + return e; + } + } + createCount.incrementAndGet(); + IntEntry created = new IntEntry(key, 0); + ConcurrentHashtable.insertHeadEntryAt(buckets, index, created); + size.incrementAndGet(); + return created; + } + } + + IntEntry remove(int key) { + int index = ConcurrentHashtable.bucketIndex(buckets, key); + synchronized (ConcurrentHashtable.getWriteLockAt(buckets, index)) { + IntEntry prev = null; + for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { + if (e.matches(key)) { + ConcurrentHashtable.unlink(buckets, index, prev, e); + size.decrementAndGet(); + return e; + } + prev = e; + } + return null; + } + } + } +}