diff --git a/internal-api/src/jmh/java/datadog/trace/util/AccumulatorBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/AccumulatorBenchmark.java new file mode 100644 index 00000000000..5bf142edfa8 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/AccumulatorBenchmark.java @@ -0,0 +1,230 @@ +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.LongAdder; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Group; +import org.openjdk.jmh.annotations.GroupThreads; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * {@link Accumulator} vs the alternatives it actually displaces: a single {@code LongAdder} (the + * collision-free baseline it can never beat, only approach), an independent {@code LongAdder} per + * counter guarded by a per-counter lock (the "just fix it with LongAdder" natural migration target + * -- {@code longAdderGroup*}), and the {@code ConcurrentHashMap.computeIfAbsent(key, k -> new + * AtomicLong())} anti-pattern ({@code chmAtomicLongIncrement*}) that {@link Accumulator} exists to + * avoid. The CHM variant allocates its counter under the bucket's bin lock the first time its one + * constant key is seen, but since the map is a {@code @State(Scope.Benchmark)} field shared across + * the whole run, that allocation happens exactly once; every sampled op after it hits the warmed, + * already-present fast path. So this measures steady-state {@code computeIfAbsent} lookup overhead + * on an already-populated map, not the one-time allocation-under-lock cost -- still a useful number + * (a fixed, small key set that's allocated once and hit for the life of the process, as {@code + * WafMetricCollector}-style CHM counters are, spends nearly all its time in this same warmed path), + * just not the pathology the name of this benchmark might suggest. + * + *

{@code longAdderGroup*}: is a "just fix it with LongAdder" helper actually cheaper? + * {@code groupInc}/{@code groupAccumulateAnd} are the natural correct fix using {@code LongAdder} + * as the payload: one {@code LongAdder} per counter, with a per-counter lock guarding both + * the increment and the drain (locking only the drain does nothing -- {@code sumThenReset()}'s + * internal race is against the {@code LongAdder}'s own CAS-based {@code add()}, not against any + * lock a caller takes). With this benchmark's single counter, that per-counter lock collapses to + * one lock shared by every thread -- no thread-based distribution at all -- so it loses badly on + * the write path against {@link Accumulator}'s thread-sharded stripes, especially under contention. + * This is the realistic production baseline this class was built to replace (see {@code + * TracerHealthMetrics}'s pre-migration design, one {@code LongAdder} field per counter): + * AccumulatorBenchmark.accumulatorIncrement_lowContention avgt 6 0.010 ± 0.001 us/op + * AccumulatorBenchmark.accumulatorIncrement_highContention avgt 6 0.025 ± 0.039 us/op + * AccumulatorBenchmark.longAdderGroupIncrement_lowContention avgt 6 0.012 ± 0.001 us/op + * AccumulatorBenchmark.longAdderGroupIncrement_highContention avgt 6 1.178 ± 0.134 us/op + * AccumulatorBenchmark.accumulatorAccumulateAndReset_lowContention avgt 6 0.104 ± 0.001 us/op + * AccumulatorBenchmark.accumulatorAccumulateAndReset_highContention avgt 6 13.357 ± 1.203 us/op + * AccumulatorBenchmark.longAdderGroupAccumulateAnd_lowContention avgt 6 0.024 ± 0.001 us/op + * AccumulatorBenchmark.longAdderGroupAccumulateAnd_highContention avgt 6 2.439 ± 0.337 us/op + * At high contention, {@link Accumulator} beats the realistic {@code longAdderGroup} + * baseline by nearly 50x on increment (the call that runs on every event), but is itself + * roughly 5.5x worse than {@code longAdderGroup} on drain under that same high-contention + * topology (the call that runs once per reporting cycle) -- {@code accumulateAndReset} walks every + * stripe with a full {@code getAndSet} per counter, so more stripes (sized for core count) means + * more per-drain work than {@code longAdderGroup}'s one-lock-per-counter {@code sumThenReset}. This + * is still a clean win once weighted by call-site frequency -- the increment win is ~50x on a call + * that fires on every event, the drain loss is ~5.5x on a call that fires once per reporting cycle + * (e.g. a 30s flush tick) -- but the drain-side regression is real, not "slightly worse," and worth + * knowing before assuming this trade is free in every topology. + */ +@State(Scope.Benchmark) +@Warmup(iterations = 1, time = 10) +@Measurement(iterations = 3, time = 10) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(MICROSECONDS) +@Fork(2) +public class AccumulatorBenchmark { + + enum Counter { + HITS + } + + private final LongAdder adder = new LongAdder(); + private final Accumulator accumulator = Accumulator.of(Counter.values()); + private final ConcurrentHashMap chm = new ConcurrentHashMap<>(); + private final LongAdder[] longAdderGroup = {new LongAdder()}; + + /** + * The natural "just use LongAdder" fix for the reset hazard: one {@code LongAdder} per counter, + * with a per-counter lock guarding both the increment and the drain -- external locking around + * only the drain does nothing, since {@code sumThenReset()}'s internal race is against the {@code + * LongAdder}'s own CAS-based {@code add()}, not against any lock a caller takes. This is the fair + * comparison point: it closes the same reset hazard {@link Accumulator} does, but stripes by + * counter (one lock per enum constant) instead of by thread (one shared table + * across all counters) -- so N threads hammering the *same* counter contend on one lock + * regardless of core count, with no thread-bucket distribution at all. + */ + private static void groupInc(LongAdder[] group, int ordinal) { + LongAdder counter = group[ordinal]; + synchronized (counter) { + counter.add(1L); + } + } + + private static long[] groupAccumulateAnd(LongAdder[] group) { + long[] acc = new long[group.length]; + for (int i = 0; i < group.length; i++) { + LongAdder counter = group[i]; + synchronized (counter) { + acc[i] = counter.sumThenReset(); + } + } + return acc; + } + + @Benchmark + @Threads(1) + public void longAdderIncrement_lowContention() { + adder.increment(); + } + + @Benchmark + @Threads(Threads.MAX) + public void longAdderIncrement_highContention() { + adder.increment(); + } + + @Benchmark + @Threads(1) + public void accumulatorIncrement_lowContention() { + accumulator.inc(Counter.HITS); + } + + @Benchmark + @Threads(Threads.MAX) + public void accumulatorIncrement_highContention() { + accumulator.inc(Counter.HITS); + } + + @Benchmark + @Threads(1) + public void chmAtomicLongIncrement_lowContention() { + chm.computeIfAbsent("hits", k -> new AtomicLong()).incrementAndGet(); + } + + @Benchmark + @Threads(Threads.MAX) + public void chmAtomicLongIncrement_highContention() { + chm.computeIfAbsent("hits", k -> new AtomicLong()).incrementAndGet(); + } + + @Benchmark + @Threads(1) + public void longAdderSumThenReset_lowContention(Blackhole blackhole) { + adder.increment(); + blackhole.consume(adder.sumThenReset()); + } + + @Benchmark + @Threads(Threads.MAX) + public void longAdderSumThenReset_highContention(Blackhole blackhole) { + adder.increment(); + blackhole.consume(adder.sumThenReset()); + } + + @Benchmark + @Threads(1) + public void accumulatorAccumulateAndReset_lowContention(Blackhole blackhole) { + accumulator.inc(Counter.HITS); + blackhole.consume(accumulator.accumulateAndReset()); + } + + /** + * A deliberately pessimistic topology: every thread both writes and drains on every op, so {@code + * Threads.MAX} threads are all draining concurrently. Real callers don't do this -- see {@code + * accumulatorMixed-write}/{@code accumulatorMixed-drain} below for the "many writers, one rare + * drainer" shape this class actually targets. Kept as the worst-case upper bound: no production + * topology should be more contended on {@link Accumulator#accumulateAndReset} than this. + */ + @Benchmark + @Threads(Threads.MAX) + public void accumulatorAccumulateAndReset_highContention(Blackhole blackhole) { + accumulator.inc(Counter.HITS); + blackhole.consume(accumulator.accumulateAndReset()); + } + + /** + * The realistic counterpart to {@code accumulatorAccumulateAndReset_highContention}: many writer + * threads incrementing, and a single dedicated thread polling {@link + * Accumulator#accumulateAndReset} -- not every thread doing both on every op. {@code + * accumulatorMixed-write} measures increment cost while a drain is actively running; {@code + * accumulatorMixed-drain} measures the drain's own cost under that same live write pressure. The + * 4:1 writer:drainer ratio is illustrative of "many writers, rare drain," not tuned to a specific + * core count. + */ + @Benchmark + @Group("accumulatorMixed") + @GroupThreads(4) + public void accumulatorMixed_write() { + accumulator.inc(Counter.HITS); + } + + @Benchmark + @Group("accumulatorMixed") + @GroupThreads(1) + public void accumulatorMixed_drain(Blackhole blackhole) { + blackhole.consume(accumulator.accumulateAndReset()); + } + + @Benchmark + @Threads(1) + public void longAdderGroupIncrement_lowContention() { + groupInc(longAdderGroup, Counter.HITS.ordinal()); + } + + @Benchmark + @Threads(Threads.MAX) + public void longAdderGroupIncrement_highContention() { + groupInc(longAdderGroup, Counter.HITS.ordinal()); + } + + @Benchmark + @Threads(1) + public void longAdderGroupAccumulateAnd_lowContention(Blackhole blackhole) { + groupInc(longAdderGroup, Counter.HITS.ordinal()); + blackhole.consume(groupAccumulateAnd(longAdderGroup)); + } + + @Benchmark + @Threads(Threads.MAX) + public void longAdderGroupAccumulateAnd_highContention(Blackhole blackhole) { + groupInc(longAdderGroup, Counter.HITS.ordinal()); + blackhole.consume(groupAccumulateAnd(longAdderGroup)); + } +} diff --git a/internal-api/src/main/java/datadog/trace/util/Accumulator.java b/internal-api/src/main/java/datadog/trace/util/Accumulator.java new file mode 100644 index 00000000000..bb830b4acce --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/util/Accumulator.java @@ -0,0 +1,210 @@ +package datadog.trace.util; + +import datadog.environment.ThreadSupport; +import java.util.concurrent.atomic.AtomicLongArray; + +/** + * A striped, lock-free counter primitive keyed by enum ordinal: {@code LongAdder}'s write + * scalability, but as one shared, thread-sharded table instead of one independent {@code LongAdder} + * per counter -- which avoids paying {@code LongAdder}'s per-instance striping overhead {@code + * E.values().length} times over. + * + *

{@code
+ * enum MyCounters { FOO, BAR }
+ *
+ * Accumulator counters = Accumulator.of(MyCounters.values());
+ * counters.inc(MyCounters.FOO);
+ * counters.add(MyCounters.BAR, 5L);
+ *
+ * Accumulator.Counts drained = counters.accumulateAndReset();
+ * long foo = drained.get(MyCounters.FOO);
+ * }
+ * + *

Each counter's own {@link #accumulateAndReset} slot is read-and-zeroed with a single atomic + * {@code getAndSet}, so -- like {@code Accumulator}'s previous {@code synchronized}-stripe design, + * and unlike {@code LongAdder#sumThenReset()} -- no individual increment can land in the gap + * between summing and zeroing and be silently lost. What's gone is the previous design's + * row-wide atomicity: {@link #inc}/{@link #add} for two different counters are no longer + * guaranteed to be seen together by a concurrent {@link #accumulateAndReset}. There is no {@code + * update}-style escape hatch for grouping several counters under one atomic operation -- callers + * needing that must weigh whether the guarantee was load-bearing (most call sites are logging + * unrelated aspects of the same event, not maintaining a cross-counter invariant a reader depends + * on) or bring their own coordination. + */ +public final class Accumulator> { + /** One full cache line of {@code long}s (64 bytes), used to pad each stripe row. */ + private static final int CACHE_LINE_LONGS = 8; + + private final AtomicLongArray[] data; + private final int width; + private final E[] values; + + private Accumulator(AtomicLongArray[] data, int width, E[] values) { + this.data = data; + this.width = width; + this.values = values; + } + + /** + * @param values the enum constants naming each counter, e.g. {@code MyCounters.values()} + */ + public static > Accumulator of(E[] values) { + int width = values.length; + int paddedWidth = paddedWidth(width); + int stripes = stripeCount(); + AtomicLongArray[] data = new AtomicLongArray[stripes]; + for (int i = 0; i < stripes; i++) { + data[i] = new AtomicLongArray(paddedWidth); + } + return new Accumulator<>(data, width, values); + } + + /** + * @param enumType the enum naming each counter, e.g. {@code MyCounters.class} + */ + public static > Accumulator of(Class enumType) { + return of(enumType.getEnumConstants()); + } + + /** Increments the counter named by {@code key} in the calling thread's stripe by one. */ + public void inc(E key) { + add(key, 1L); + } + + /** Adds {@code delta} to the counter named by {@code key} in the calling thread's stripe. */ + public void add(E key, long delta) { + stripeOf(data).getAndAdd(key.ordinal(), delta); + } + + /** + * Combines and resets every stripe, returning the sum as a typed view. Each counter is + * read-and-zeroed with one atomic {@code getAndSet} -- see the class-level note on what atomicity + * this does and doesn't provide across different counters. + * + * @return the sum, keyed by the enum's {@code ordinal()} + */ + public Counts accumulateAndReset() { + long[] acc = new long[width]; + for (AtomicLongArray stripe : data) { + for (int i = 0; i < width; i++) { + acc[i] += stripe.getAndSet(i, 0L); + } + } + return new Counts<>(acc, values); + } + + /** + * Combines every stripe without resetting it, returning the sum as a typed view -- a live, + * non-destructive snapshot for a diagnostic read (e.g. {@code summary()}) that must not perturb + * the delta a concurrent {@link #accumulateAndReset} on a reporting cadence is about to report. + * + * @return the sum, keyed by the enum's {@code ordinal()} + */ + public Counts sum() { + long[] acc = new long[width]; + for (AtomicLongArray stripe : data) { + for (int i = 0; i < width; i++) { + acc[i] += stripe.get(i); + } + } + return new Counts<>(acc, values); + } + + /** + * A typed view over a drained snapshot, returned by {@link #accumulateAndReset} or {@link #sum}: + * the same enum-ordinal type checking {@link Accumulator} provides on writes, applied to the read + * side too. + */ + public static final class Counts> { + private final long[] counts; + private final E[] values; + + private Counts(long[] counts, E[] values) { + this.counts = counts; + this.values = values; + } + + /** + * An all-zero {@link Counts}, sized for {@code values} -- for seeding a running total before + * any real drain has happened, without needing a scratch {@link Accumulator} just to call + * {@link Accumulator#sum()} on it. + * + * @param values the enum constants naming each counter, e.g. {@code MyCounters.values()} + */ + public static > Counts zero(E[] values) { + return new Counts<>(new long[values.length], values); + } + + /** + * @param enumType the enum naming each counter, e.g. {@code MyCounters.class} + * @see #zero(Enum[]) + */ + public static > Counts zero(Class enumType) { + return zero(enumType.getEnumConstants()); + } + + /** The counter named by {@code key}. */ + public long get(E key) { + return counts[key.ordinal()]; + } + + /** + * The enum constants this {@link Counts} is keyed by, in declaration order -- for a caller that + * wants to iterate every counter (e.g. reporting each one) without separately having to pass + * {@code E.values()} alongside this object. + */ + public E[] keys() { + return values; + } + + /** + * Adds {@code other} to this, key by key, returning a new {@link Counts} rather than mutating + * either input -- combines a stored running total with a fresh, non-destructive {@link #sum} to + * answer "what's the live total right now" without ever resetting anything. + */ + public Counts plus(Counts other) { + long[] combined = counts.clone(); + for (int i = 0; i < combined.length; i++) { + combined[i] += other.counts[i]; + } + return new Counts<>(combined, values); + } + } + + /** + * The calling thread's stripe: cheap masking, no allocation, no map lookup. + * + *

Multiple threads can map to the same stripe (this is masking, not a bijection); each + * counter's own atomic slot makes that safe, just not maximally scalable under a hash collision. + */ + private static AtomicLongArray stripeOf(AtomicLongArray[] data) { + int mask = data.length - 1; + int idx = (int) (ThreadSupport.threadId() & mask); + return data[idx]; + } + + /** + * A fixed, power-of-two stripe count deliberately oversized to roughly 2x {@link + * Runtime#availableProcessors()} (minimum 4). Not exposed as a per-call override: a mandatory + * sizing knob on every caller fails the "print test" of self-explanatory API design. + * + *

Sizing to exactly the core count leaves stripe collisions likely under real contention + * (birthday-paradox math: with {@code n} contending threads and {@code m} stripes, expected + * colliding pairs are {@code n(n-1)/(2m)}) -- and a collision costs CAS-retry/cache-line-bounce + * cost, the same problem {@code LongAdder}'s own {@code Cell[]} table exists to avoid. Doubling + * the stripe count roughly halves that collision count for a one-time, per-accumulator memory + * cost, at the price of a slightly more expensive (but far rarer) {@link #accumulateAndReset} + * drain -- the right trade given {@link #inc}/{@link #add} run on every call while {@link + * #accumulateAndReset} runs on a reporting cadence. + */ + private static int stripeCount() { + int cpus = Runtime.getRuntime().availableProcessors(); + return Math.max(4, 2 * Integer.highestOneBit(Math.max(1, cpus))); + } + + /** Rounds {@code width} up to a whole number of cache lines, plus one full trailing line. */ + private static int paddedWidth(int width) { + int wholeLines = ((width + CACHE_LINE_LONGS - 1) / CACHE_LINE_LONGS) * CACHE_LINE_LONGS; + return wholeLines + CACHE_LINE_LONGS; + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/AccumulatorFootprintTest.java b/internal-api/src/test/java/datadog/trace/util/AccumulatorFootprintTest.java new file mode 100644 index 00000000000..a8f2f6f39d8 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/AccumulatorFootprintTest.java @@ -0,0 +1,140 @@ +package datadog.trace.util; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +import datadog.environment.JavaVirtualMachine; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.LongAdder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.openjdk.jol.info.GraphLayout; + +/** + * Retained-footprint comparison (JOL) for {@link Accumulator} vs the alternative it actually + * displaces: one {@code LongAdder} per counter (there is no multi-counter {@code LongAdder} -- a + * caller wanting N additive counters allocates N of them, one field each, as {@code OtlpTelemetry} + * and {@code PayloadDispatcherImpl} do today). + * + *

A freshly constructed {@code LongAdder} is nearly free -- it holds no {@code Cell[]} table + * until contention forces one -- so comparing fresh instances understates its real cost and + * flatters {@code LongAdder}. {@link Accumulator} pays its full striped array up front, at + * creation, sized for {@link Runtime#availableProcessors()} regardless of whether contention ever + * materializes. The realistic comparison is therefore not fresh-vs-fresh but contended-vs-fresh: + * what each actually costs once the counters they represent are hit by real concurrent writers, as + * they are on the telemetry paths this class targets. + * + *

{@link Accumulator}'s stripe count is fixed at creation (roughly 2x {@link + * Runtime#availableProcessors()}, minimum 4) and does not grow further as more contention arrives + * within it, while every additional concurrently-written {@code LongAdder} keeps paying its own + * {@code Cell[]} growth cost independently. {@code Accumulator}'s up-front cost is the more + * predictable one: fixed at creation, independent of runtime contention, and shared (one striped + * table) across however many counters the caller's enum declares, rather than paid per counter. The + * printed numbers below vary by run/JVM -- see the assertions for the invariants that actually + * matter. + */ +class AccumulatorFootprintTest { + + enum Counters { + REQUESTS, + ERRORS, + RETRIES, + BYTES_SENT + } + + @BeforeAll + static void assumeNotJ9Jvm() { + // JOL's GraphLayout relies on HotSpot-specific Unsafe internals and throws + // IllegalStateException on J9-based JVMs (IBM/Semeru) -- same guard as + // StringIndexFootprintTest / ScopeAndContinuationLayoutTest. + assumeFalse(JavaVirtualMachine.isJ9()); + } + + static long bytes(Object root) { + return GraphLayout.parseInstance(root).totalSize(); + } + + static LongAdder[] freshAdders() { + LongAdder[] adders = new LongAdder[Counters.values().length]; + for (int i = 0; i < adders.length; i++) { + adders[i] = new LongAdder(); + } + return adders; + } + + @Test + void freshFootprint() { + LongAdder[] adders = freshAdders(); + Accumulator accumulator = Accumulator.of(Counters.values()); + + long adderBytes = bytes((Object) adders); + long accumulatorBytes = bytes(accumulator); + + System.out.printf( + "fresh: %d LongAdders = %6d bytes, Accumulator = %6d bytes%n", + adders.length, adderBytes, accumulatorBytes); + } + + /** + * Drives real multi-threaded contention against a fresh set of {@code LongAdder}s to force their + * {@code Cell[]} tables to grow, then compares against {@link Accumulator}'s fixed footprint -- + * the realistic comparison, since production callers write to these counters concurrently rather + * than leaving them untouched. + * + *

Cell-table growth is driven by JVM-internal CAS-collision detection, not something this test + * controls directly, so the exact grown size can vary by run/JVM; the one invariant asserted is + * monotonic growth (a contended footprint can only be at least the fresh one). + */ + @Test + void contendedFootprint() throws InterruptedException { + LongAdder[] adders = freshAdders(); + long freshAdderBytes = bytes((Object) adders); + + int threads = Math.min(16, Math.max(4, Runtime.getRuntime().availableProcessors())); + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + try { + for (int t = 0; t < threads; t++) { + pool.execute( + () -> { + try { + start.await(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (System.nanoTime() < deadline) { + for (LongAdder adder : adders) { + adder.increment(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + } + start.countDown(); + assertTrue(done.await(30, TimeUnit.SECONDS)); + } finally { + pool.shutdown(); + } + + long contendedAdderBytes = bytes((Object) adders); + Accumulator accumulator = Accumulator.of(Counters.values()); + long accumulatorBytes = bytes(accumulator); + + System.out.printf( + "contended: %d LongAdders = %6d bytes, Accumulator = %6d bytes%n", + adders.length, contendedAdderBytes, accumulatorBytes); + + assertTrue( + contendedAdderBytes >= freshAdderBytes, + "contended LongAdder footprint should never shrink below the fresh footprint"); + assertTrue( + accumulatorBytes < contendedAdderBytes, + "Accumulator's fixed footprint should be smaller than N contended LongAdders"); + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/AccumulatorTest.java b/internal-api/src/test/java/datadog/trace/util/AccumulatorTest.java new file mode 100644 index 00000000000..f17eab823fb --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/AccumulatorTest.java @@ -0,0 +1,241 @@ +package datadog.trace.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; + +class AccumulatorTest { + + enum Counters { + FOO, + BAR, + BAZ + } + + @Test + void freshAccumulatorSumsToZero() { + Accumulator counters = Accumulator.of(Counters.values()); + Accumulator.Counts drained = counters.accumulateAndReset(); + for (Counters c : Counters.values()) { + assertEquals(0L, drained.get(c)); + } + } + + @Test + void incIncrementsByOne() { + Accumulator counters = Accumulator.of(Counters.values()); + counters.inc(Counters.FOO); + counters.inc(Counters.FOO); + counters.inc(Counters.BAR); + + Accumulator.Counts drained = counters.accumulateAndReset(); + assertEquals(2L, drained.get(Counters.FOO)); + assertEquals(1L, drained.get(Counters.BAR)); + assertEquals(0L, drained.get(Counters.BAZ)); + } + + @Test + void addAppliesArbitraryDelta() { + Accumulator counters = Accumulator.of(Counters.values()); + counters.add(Counters.BAZ, 41L); + counters.add(Counters.BAZ, 1L); + + Accumulator.Counts drained = counters.accumulateAndReset(); + assertEquals(42L, drained.get(Counters.BAZ)); + } + + @Test + void accumulateAndResetsSoASecondDrainIsZero() { + Accumulator counters = Accumulator.of(Counters.values()); + counters.inc(Counters.FOO); + + Accumulator.Counts first = counters.accumulateAndReset(); + assertEquals(1L, first.get(Counters.FOO)); + + Accumulator.Counts second = counters.accumulateAndReset(); + for (Counters c : Counters.values()) { + assertEquals(0L, second.get(c)); + } + } + + @Test + void concurrentIncrementsAreNotLost() throws InterruptedException { + Accumulator counters = Accumulator.of(Counters.values()); + int threadCount = 16; + int incrementsPerThread = 10_000; + + ExecutorService pool = Executors.newFixedThreadPool(threadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + try { + for (int t = 0; t < threadCount; t++) { + pool.execute( + () -> { + try { + start.await(); + for (int i = 0; i < incrementsPerThread; i++) { + counters.inc(Counters.FOO); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + } + start.countDown(); + assertTrue(done.await(30, TimeUnit.SECONDS)); + } finally { + pool.shutdown(); + } + + Accumulator.Counts drained = counters.accumulateAndReset(); + assertEquals((long) threadCount * incrementsPerThread, drained.get(Counters.FOO)); + } + + @Test + void concurrentAccumulateAndDuringWritesNeverExceedsWritten() + throws InterruptedException, ExecutionException, TimeoutException { + Accumulator counters = Accumulator.of(Counters.values()); + int threadCount = 8; + int incrementsPerThread = 5_000; + + ExecutorService pool = Executors.newFixedThreadPool(threadCount + 1); + CountDownLatch done = new CountDownLatch(threadCount); + AtomicBoolean stop = new AtomicBoolean(false); + long[] runningTotal = {0L}; + + try { + Future drainer = + pool.submit( + () -> { + while (!stop.get()) { + Accumulator.Counts drained = counters.accumulateAndReset(); + synchronized (runningTotal) { + runningTotal[0] += drained.get(Counters.FOO); + } + } + }); + + for (int t = 0; t < threadCount; t++) { + pool.execute( + () -> { + for (int i = 0; i < incrementsPerThread; i++) { + counters.inc(Counters.FOO); + } + done.countDown(); + }); + } + + assertTrue(done.await(30, TimeUnit.SECONDS)); + stop.set(true); + drainer.get(30, TimeUnit.SECONDS); + + Accumulator.Counts finalDrain = counters.accumulateAndReset(); + synchronized (runningTotal) { + runningTotal[0] += finalDrain.get(Counters.FOO); + } + + assertEquals((long) threadCount * incrementsPerThread, runningTotal[0]); + } finally { + pool.shutdown(); + } + } + + @Test + void sumDoesNotResetStripes() { + Accumulator counters = Accumulator.of(Counters.values()); + counters.inc(Counters.FOO); + + Accumulator.Counts first = counters.sum(); + assertEquals(1L, first.get(Counters.FOO)); + + // sum() didn't reset anything, so a second sum() sees the same total + Accumulator.Counts second = counters.sum(); + assertEquals(1L, second.get(Counters.FOO)); + + // and a real drain afterwards still sees the value sum() didn't consume + Accumulator.Counts drained = counters.accumulateAndReset(); + assertEquals(1L, drained.get(Counters.FOO)); + } + + @Test + void sumReflectsIncrementsMadeAfterAnEarlierSum() { + Accumulator counters = Accumulator.of(Counters.values()); + counters.inc(Counters.FOO); + counters.sum(); + + counters.inc(Counters.FOO); + Accumulator.Counts second = counters.sum(); + assertEquals(2L, second.get(Counters.FOO)); + } + + @Test + void zeroSeedsAnAllZeroCountsWithoutAScratchAccumulator() { + Accumulator.Counts zero = Accumulator.Counts.zero(Counters.values()); + assertEquals(0L, zero.get(Counters.FOO)); + assertEquals(0L, zero.get(Counters.BAR)); + + Accumulator counters = Accumulator.of(Counters.values()); + counters.inc(Counters.FOO); + + Accumulator.Counts live = zero.plus(counters.sum()); + assertEquals(1L, live.get(Counters.FOO)); + } + + @Test + void ofAndZeroAcceptAnEnumClassInsteadOfAValuesArray() { + Accumulator counters = Accumulator.of(Counters.class); + counters.inc(Counters.FOO); + + Accumulator.Counts zero = Accumulator.Counts.zero(Counters.class); + Accumulator.Counts live = zero.plus(counters.sum()); + assertEquals(1L, live.get(Counters.FOO)); + } + + @Test + void countsExposesItsOwnKeysWithoutASeparateValuesArray() { + Accumulator counters = Accumulator.of(Counters.values()); + counters.inc(Counters.FOO); + counters.add(Counters.BAR, 5L); + + Accumulator.Counts drained = counters.accumulateAndReset(); + assertEquals(Counters.values().length, drained.keys().length); + + long total = 0L; + for (Counters c : drained.keys()) { + total += drained.get(c); + } + assertEquals(6L, total); + } + + @Test + void plusCombinesAStoredRunningTotalWithALiveSumWithoutMutatingEither() { + Accumulator counters = Accumulator.of(Counters.values()); + counters.inc(Counters.FOO); + counters.add(Counters.BAR, 5L); + + // drain once, e.g. as if a reporting cycle already ran and stored this total + Accumulator.Counts storedTotal = counters.accumulateAndReset(); + + // more activity happens after that drain, before the next one + counters.inc(Counters.FOO); + + Accumulator.Counts live = storedTotal.plus(counters.sum()); + assertEquals(2L, live.get(Counters.FOO)); + assertEquals(5L, live.get(Counters.BAR)); + + // neither input was mutated by combining them + assertEquals(1L, storedTotal.get(Counters.FOO)); + assertEquals(1L, counters.sum().get(Counters.FOO)); + } +}