diff --git a/utils/queue-utils/build.gradle.kts b/utils/queue-utils/build.gradle.kts
index 6b72ab9e6a0..2184464b06c 100644
--- a/utils/queue-utils/build.gradle.kts
+++ b/utils/queue-utils/build.gradle.kts
@@ -4,6 +4,7 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion
plugins {
`java-library`
id("dd-trace-java.module.internal-library")
+ id("dd-trace-java.jmh-conventions")
}
dependencies {
diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionAlternativesBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionAlternativesBenchmark.java
new file mode 100644
index 00000000000..b899d95bda9
--- /dev/null
+++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionAlternativesBenchmark.java
@@ -0,0 +1,259 @@
+package datadog.common.queue;
+
+import java.util.Queue;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.jctools.queues.MpscArrayQueue;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+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 WorkQueue} against the things a caller would otherwise use, including the two it is
+ * actually replacing. Published because the comparison exists whether or not we run it, and a
+ * reader who has to measure it themselves is entitled to wonder what we found.
+ *
+ *
+ * ./gradlew :utils:queue-utils:jmh -Pjmh.includes=AdmissionAlternatives -Pjmh.profilers=gc
+ *
+ *
+ * The decision this table is for. Use {@link MpscArrayQueue} directly when one consumer,
+ * the ring's own bound, and an element you already hold are the whole requirement -- it is the
+ * floor here and nothing built on top of it will beat it. Reach for {@link WorkQueue} when the
+ * element costs something to build, when a drop needs counting, when a failed consumer needs a
+ * retry or a handler, or when the queue has a lifecycle. That is a real choice with a real answer
+ * on both sides, and the numbers below are what it costs either way.
+ *
+ *
The baselines are not inventions. {@code arrayBlocking} is {@code WafMetricCollector},
+ * which offers into an {@code ArrayBlockingQueue(1024)}; {@code linkedBlocking} is {@code
+ * RumInjectorMetrics}, which offers into a {@code LinkedBlockingQueue(1024)} and drops the return
+ * value. Both build their element first and find out afterwards whether there was room. {@code
+ * clqWithCounter} is the other thing people write: a {@link ConcurrentLinkedQueue} with an {@link
+ * AtomicInteger} in front of it, guarded by a read and then incremented -- check-then-act, so two
+ * threads at the boundary can both pass, and the bound is a suggestion. It is here because it is
+ * common, not because it is correct; that it is racy is part of what is being compared.
+ *
+ *
Two halves, because the answer differs. The {@code steady} arms admit and drain at one
+ * thread: the per-operation cost with nothing else happening, which is where the alternatives look
+ * their best. The {@code refused} arms sit on a full queue at four threads: the boundary, where a
+ * bounded queue spends its time under load, and where building an element before asking is a wasted
+ * allocation on every call. Read both. A caller whose queue is never full lives in the first table
+ * and should weigh the API for what it buys, not for its speed.
+ *
+ *
Results. JDK 17, one machine, {@code -Pjmh.forks=1}; the four-thread arms carry wide error
+ * bars and the ranking within the incumbents is not meaningful, but the separation from {@code
+ * refusedWorkQueue} is an order of magnitude and survives any reading of them.
+ *
+ *
+ * Benchmark threads ns/op B/op
+ * steadyRawMpsc 1 24.2 24
+ * steadyWorkQueue 1 36.3 24
+ * steadyArrayBlocking 1 38.8 24
+ * steadyClqWithCounter 1 42.8 48
+ * steadyLinkedBlocking 1 46.2 48
+ * refusedWorkQueue 4 7.2 0
+ * refusedClqWithCounter 4 146.1 24
+ * refusedLinkedBlocking 4 148.0 24
+ * refusedRawMpsc 4 160.2 24
+ * refusedArrayBlocking 4 180.5 24
+ *
+ *
+ * What the two halves say. Admitting, the raw ring is the floor at 24ns and nothing here
+ * reaches it; {@link WorkQueue} costs 12ns more for the counted drop, the lifecycle, and the
+ * producer callback. Both incumbents cost more than that, and the two linked queues allocate a node
+ * per element on top of the element itself. So the API is not the expensive option even in the case
+ * that flatters the alternatives.
+ *
+ *
Refusing, the separation is not subtle, and it is not really about the queue. {@code
+ * refusedWorkQueue} does no allocation at all, because the place is claimed before the producer is
+ * ever called and there was no place; every other arm has already built its element by the time it
+ * asks. That is the designed difference rather than an artifact of the harness -- but read it as
+ * such. A caller whose element is a preexisting object, or is free to build, keeps the shape of
+ * this gap and not its size.
+ *
+ *
The honest caveat. {@code refusedRawMpsc} is in the same band as the incumbents, which
+ * is the reminder that the floor is a floor for admitting, not for refusing: a full ring still
+ * touches a line the consumer is moving. {@code refusedWorkQueue} is fast because refusal is a load
+ * against a counter no refusing thread writes -- see {@code ContendedAdmissionBenchmark}, which is
+ * where that came from and what it cost before.
+ */
+@Fork(2)
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@State(Scope.Benchmark)
+public class AdmissionAlternativesBenchmark {
+
+ private static final int CAPACITY = 1024;
+
+ private static final String ELEMENT = "element";
+
+ /**
+ * What the incumbents build before they ask. Stands in for a {@code WafMetric} or a metric
+ * sample: not exact, but a real allocation with a stable footprint, so the arm that builds one
+ * can be told from the arm that does not.
+ */
+ static final class Payload {
+ final Object a;
+ final long timestamp;
+
+ Payload(Object a, long timestamp) {
+ this.a = a;
+ this.timestamp = timestamp;
+ }
+ }
+
+ /** Only ever called once a place is already claimed, which is the whole difference. */
+ private static final Producer BUILDER = () -> new Payload(ELEMENT, System.nanoTime());
+
+ private MpscArrayQueue steadyRaw;
+ private BlockingQueue steadyArrayBlocking;
+ private BlockingQueue steadyLinkedBlocking;
+ private Queue steadyClq;
+ private AtomicInteger steadyClqCount;
+ private WorkQueue steadyWork;
+
+ private MpscArrayQueue fullRaw;
+ private BlockingQueue fullArrayBlocking;
+ private BlockingQueue fullLinkedBlocking;
+ private Queue fullClq;
+ private AtomicInteger fullClqCount;
+ private WorkQueue fullWork;
+
+ @Setup
+ public void setUp() {
+ steadyRaw = new MpscArrayQueue<>(CAPACITY);
+ steadyArrayBlocking = new ArrayBlockingQueue<>(CAPACITY);
+ steadyLinkedBlocking = new LinkedBlockingQueue<>(CAPACITY);
+ steadyClq = new ConcurrentLinkedQueue<>();
+ steadyClqCount = new AtomicInteger();
+ steadyWork = WorkQueues.createMpscQueue(CAPACITY);
+
+ fullRaw = new MpscArrayQueue<>(16);
+ fullArrayBlocking = new ArrayBlockingQueue<>(16);
+ fullLinkedBlocking = new LinkedBlockingQueue<>(16);
+ fullClq = new ConcurrentLinkedQueue<>();
+ fullClqCount = new AtomicInteger();
+ fullWork = WorkQueues.createMpscQueue(16);
+ for (int i = 0; i < 16; i++) {
+ Payload payload = new Payload(ELEMENT, i);
+ fullRaw.offer(payload);
+ fullArrayBlocking.offer(payload);
+ fullLinkedBlocking.offer(payload);
+ fullClq.offer(payload);
+ fullClqCount.incrementAndGet();
+ fullWork.tryPut(payload);
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Steady: admit one, drain one, at a single thread. Every alternative at its best.
+ // ---------------------------------------------------------------------------------------------
+
+ @Benchmark
+ @Threads(1)
+ public void steadyRawMpsc(Blackhole bh) {
+ bh.consume(steadyRaw.offer(new Payload(ELEMENT, System.nanoTime())));
+ bh.consume(steadyRaw.poll());
+ }
+
+ /** {@code WafMetricCollector}: build, then offer, then find out. */
+ @Benchmark
+ @Threads(1)
+ public void steadyArrayBlocking(Blackhole bh) {
+ bh.consume(steadyArrayBlocking.offer(new Payload(ELEMENT, System.nanoTime())));
+ bh.consume(steadyArrayBlocking.poll());
+ }
+
+ /** {@code RumInjectorMetrics}: the same, over a linked queue. */
+ @Benchmark
+ @Threads(1)
+ public void steadyLinkedBlocking(Blackhole bh) {
+ bh.consume(steadyLinkedBlocking.offer(new Payload(ELEMENT, System.nanoTime())));
+ bh.consume(steadyLinkedBlocking.poll());
+ }
+
+ /** The hand-rolled bound: a read, then an increment, with a window between them. */
+ @Benchmark
+ @Threads(1)
+ public void steadyClqWithCounter(Blackhole bh) {
+ if (steadyClqCount.get() < CAPACITY) {
+ steadyClqCount.incrementAndGet();
+ bh.consume(steadyClq.offer(new Payload(ELEMENT, System.nanoTime())));
+ }
+ if (steadyClq.poll() != null) {
+ steadyClqCount.decrementAndGet();
+ }
+ }
+
+ @Benchmark
+ @Threads(1)
+ public void steadyWorkQueue(Blackhole bh) {
+ bh.consume(steadyWork.tryPut(BUILDER));
+ steadyWork.process(bh::consume);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Refused: a full queue, four threads. The boundary, where a bounded queue lives under load.
+ // ---------------------------------------------------------------------------------------------
+
+ @Benchmark
+ @Threads(4)
+ public void refusedRawMpsc(Blackhole bh) {
+ Payload payload = new Payload(ELEMENT, System.nanoTime());
+ bh.consume(fullRaw.offer(payload));
+ bh.consume(payload);
+ }
+
+ @Benchmark
+ @Threads(4)
+ public void refusedArrayBlocking(Blackhole bh) {
+ Payload payload = new Payload(ELEMENT, System.nanoTime());
+ bh.consume(fullArrayBlocking.offer(payload));
+ bh.consume(payload);
+ }
+
+ @Benchmark
+ @Threads(4)
+ public void refusedLinkedBlocking(Blackhole bh) {
+ Payload payload = new Payload(ELEMENT, System.nanoTime());
+ bh.consume(fullLinkedBlocking.offer(payload));
+ bh.consume(payload);
+ }
+
+ /**
+ * The one arm where the hand-rolled guard is doing what it was written for: refusing without
+ * touching the queue. It still builds the element first, because the caller had no way to know.
+ */
+ @Benchmark
+ @Threads(4)
+ public void refusedClqWithCounter(Blackhole bh) {
+ Payload payload = new Payload(ELEMENT, System.nanoTime());
+ if (fullClqCount.get() < 16) {
+ fullClqCount.incrementAndGet();
+ bh.consume(fullClq.offer(payload));
+ }
+ bh.consume(payload);
+ }
+
+ /** Never asked to build, because the place is claimed first and there was none. */
+ @Benchmark
+ @Threads(4)
+ public void refusedWorkQueue(Blackhole bh) {
+ bh.consume(fullWork.tryPut(BUILDER));
+ }
+}
diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionBenchmark.java
new file mode 100644
index 00000000000..8dd833bdd9a
--- /dev/null
+++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionBenchmark.java
@@ -0,0 +1,228 @@
+package datadog.common.queue;
+
+import java.util.concurrent.TimeUnit;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+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;
+import org.openjdk.jmh.infra.Blackhole;
+
+/**
+ * The three admission forms measured against each other on a real queue, to answer one question:
+ * does the reservation object survive escape analysis? If it does, the reserve route costs what the
+ * producer routes cost and {@link BiContextualProducer} was never needed for client-side stats. If
+ * it does not, the producer callbacks are earning their contortions.
+ *
+ * ./gradlew :utils:queue-utils:jmh -Pjmh.includes=Admission -Pjmh.profilers=gc
+ *
+ * {@code gc.alloc.rate.norm} is the number that answers it; the timings are secondary and are
+ * muddied on purpose, because every arm consumes the item it just admitted to keep the queue at
+ * steady state. The element itself is preallocated in every arm, including the producer ones, so
+ * what is being compared is the admission machinery and not the cost of building an element.
+ *
+ *
The {@code backings} parameter is the template-method question. {@code store} and {@code
+ * retrieve} are one call site each, shared by every backing in the process, so their receiver
+ * profile is global: {@code ONE} loads a single concrete subclass and C2 inlines through them,
+ * {@code BOTH} loads two and it still does behind a type guard, {@code THREE} is the cliff. Every
+ * arm drives two extra queues through the same sites for the same number of iterations, so the
+ * traffic is identical and only the number of distinct types in it changes.
+ *
+ *
Results:
+ *
+ *
+ * Benchmark ONE BOTH THREE THREE-ONE
+ * tryPutElement 21.19 20.88 22.44 +1.3
+ * tryPutProducer 20.82 20.89 21.81 +1.0
+ * tryPutContextual 20.83 20.86 22.85 +2.0
+ * tryPutBiContextual 21.25 21.29 21.83 +0.6
+ * reserveAndFill 21.04 22.92 27.61 +6.6
+ * reserveMixed 12.69 12.75 13.71 +1.0
+ * reserveRefused 8.45 7.25 7.16 0
+ *
+ *
+ * JDK 25, {@code -Pjmh.forks=2}, ns/op. Every arm allocates 0.01 B/op or less, {@code THREE}
+ * included. Error bars are within ±0.6 except {@code reserveAndFill} at {@code BOTH} (±4.3) and
+ * {@code THREE} (±2.3), and {@code reserveRefused} at {@code ONE} (±1.9).
+ *
+ *
Two things to read off it. A second backing is free — every arm is flat from {@code ONE} to
+ * {@code BOTH}. A third is not free but is small: one to two nanoseconds on a twenty-one nanosecond
+ * operation, because an admit-and-drain pays two uncontended atomics and a ring compare-and-set,
+ * and an out-of-line call is little against memory ordering. {@code reserveRefused} is the control:
+ * it never reaches {@code store}, and it does not move.
+ *
+ *
{@code reserveAndFill} is the exception worth knowing about, at roughly 30%. Its {@code store}
+ * happens inside {@link Reservation#fill} on an object that only exists if escape analysis deletes
+ * it, so that arm is not paying for a virtual call so much as for a longer chain of optimizations
+ * having to survive one. The allocation still goes away — the reservation is still scalar-replaced
+ * at {@code THREE} — but the call it wraps no longer folds into the caller. A caller admitting
+ * through a reservation is the one with something to lose from a third backing.
+ *
+ *
The refusal-design figure that {@link BaseWorkQueue} cites lives here too, and is unchanged by
+ * any of the above: {@code reserveMixed} measured 12 B/op when a refused reservation was a shared
+ * singleton and 0 B/op when it is its own allocation, on JDK 17. Only that arm can tell the two
+ * designs apart — {@code reserveAndFill} and {@code reserveRefused} each see a single outcome, so
+ * C2 prunes the branch that never runs and there is no merge left to defeat escape analysis.
+ *
+ *
{@link ThirdBackingWorkQueue} is the third type, and lives in this source set rather than in
+ * the module: the question is what a third backing would cost, and shipping one to find out
+ * would answer a different question.
+ */
+@Fork(2)
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 1)
+@Threads(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@State(Scope.Thread)
+public class AdmissionBenchmark {
+
+ public enum Backings {
+ ONE,
+ BOTH,
+ THREE
+ }
+
+ private static final String ELEMENT = "element";
+
+ private static final Producer PRODUCER = () -> ELEMENT;
+
+ private static final ContextualProducer CONTEXTUAL = context -> context;
+
+ private static final BiContextualProducer BI_CONTEXTUAL =
+ (first, second) -> first;
+
+ @Param({"ONE", "BOTH", "THREE"})
+ public Backings backings;
+
+ /** Alternates the reserving queue in {@link #reserveMixed}, so one site sees both outcomes. */
+ private int mixer;
+
+ /** The queue under test. */
+ private WorkQueue queue;
+
+ /** Kept full for the whole run, so its reservations are always refused. */
+ private WorkQueue full;
+
+ /**
+ * Two more queues driven through the same call sites as the queue under test, and the whole of
+ * what the parameter varies. Every arm allocates both and drives both, so the traffic arriving at
+ * {@code store} and {@code retrieve} is identical; only the number of distinct types in it
+ * changes. An arm that skipped the loop would also differ in how hard its call sites had been
+ * exercised before measurement, which is not the question being asked.
+ */
+ private WorkQueue second;
+
+ private WorkQueue third;
+
+ @Setup
+ public void setUp() {
+ queue = WorkQueues.createMpscQueue(1024);
+ full = WorkQueues.createMpscQueue(1);
+ full.tryPut(ELEMENT);
+ second =
+ backings == Backings.ONE
+ ? WorkQueues.createMpscQueue(1024)
+ : WorkQueues.createMpmcQueue(1024);
+ third =
+ backings == Backings.THREE
+ ? new ThirdBackingWorkQueue<>(1024)
+ : WorkQueues.createMpscQueue(1024);
+ }
+
+ /**
+ * Runs the other backings through the same methods, so every loaded type reaches the shared call
+ * sites. Repeated before every iteration rather than once per trial: the measured loop drives one
+ * type only, and a profile that saw the others just once at startup is not the profile a process
+ * with several live backings actually has.
+ */
+ @Setup(Level.Iteration)
+ public void pollute(Blackhole bh) {
+ for (int i = 0; i < 20_000; i++) {
+ second.tryPut(ELEMENT);
+ second.process(bh::consume);
+ third.tryPut(ELEMENT);
+ third.process(bh::consume);
+ }
+ }
+
+ @Benchmark
+ public void tryPutElement(Blackhole bh) {
+ bh.consume(queue.tryPut(ELEMENT));
+ queue.process(bh::consume);
+ }
+
+ @Benchmark
+ public void tryPutProducer(Blackhole bh) {
+ bh.consume(queue.tryPut(PRODUCER));
+ queue.process(bh::consume);
+ }
+
+ @Benchmark
+ public void tryPutContextual(Blackhole bh) {
+ bh.consume(queue.tryPut(ELEMENT, CONTEXTUAL));
+ queue.process(bh::consume);
+ }
+
+ @Benchmark
+ public void tryPutBiContextual(Blackhole bh) {
+ bh.consume(queue.tryPut(ELEMENT, ELEMENT, BI_CONTEXTUAL));
+ queue.process(bh::consume);
+ }
+
+ @Benchmark
+ public void reserveAndFill(Blackhole bh) {
+ Reservation place = queue.tryReserve();
+ try {
+ if (place.granted()) {
+ place.fill(ELEMENT);
+ }
+ } finally {
+ place.close();
+ }
+ queue.process(bh::consume);
+ }
+
+ /** The refusal path, which is where the shared singleton was supposed to be paying off. */
+ @Benchmark
+ public void reserveRefused(Blackhole bh) {
+ Reservation place = full.tryReserve();
+ try {
+ bh.consume(place.granted());
+ } finally {
+ place.close();
+ }
+ }
+
+ /**
+ * Both outcomes through one call site, which is the only shape where how a refusal is represented
+ * can cost anything.
+ *
+ * The two arms above each see a single outcome, so C2 prunes the branch that never runs and
+ * there is no merge to defeat escape analysis — they read zero whether a refusal is a shared
+ * singleton or its own allocation, and neither one can tell the two designs apart. A caller whose
+ * queue is nearly always accepting is genuinely in that case. A caller that sits at the boundary,
+ * refusing about as often as it admits, is in this one.
+ */
+ @Benchmark
+ public void reserveMixed(Blackhole bh) {
+ WorkQueue target = (mixer++ & 1) == 0 ? queue : full;
+ Reservation place = target.tryReserve();
+ try {
+ if (place.granted()) {
+ place.fill(ELEMENT);
+ }
+ } finally {
+ place.close();
+ }
+ queue.process(bh::consume);
+ }
+}
diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/BackingOverheadBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/BackingOverheadBenchmark.java
new file mode 100644
index 00000000000..ebc36e7d148
--- /dev/null
+++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/BackingOverheadBenchmark.java
@@ -0,0 +1,99 @@
+package datadog.common.queue;
+
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.TimeUnit;
+import org.jctools.queues.MpmcArrayQueue;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+
+/**
+ * The two structures the multi-consumer backing could sit on, priced against each other. This is
+ * the measurement {@link MpmcWorkQueue} chose the array ring on.
+ *
+ * ./gradlew :utils:queue-utils:jmh -Pjmh.includes=BackingOverhead -Pjmh.profilers=gc
+ *
+ * The element is a preallocated singleton in every arm, so the only allocation measured is the
+ * structure's own — which is the point, because the linked queue builds a node per element and the
+ * ring builds nothing.
+ *
+ *
+ * Benchmark threads ns/op B/op
+ * mpmcSteady 1 12.7 0
+ * clqSteady 1 20.6 24
+ * mpmcContended 4 559 0
+ * clqContended 4 625 24
+ *
+ *
+ * Uncontended the ring is about 38% cheaper and stops manufacturing 24 bytes of garbage per
+ * element, which at a million elements a second is 24MB/s the linked queue creates and the ring
+ * does not. The four-thread arms are ±287 and ±540 respectively and say nothing: each thread offers
+ * and polls the same queue, so both ends of it are thrashed and the measurement is of the harness.
+ * They are kept because leaving them out would imply the contended case was measured and settled.
+ *
+ *
What the ring costs in exchange is that it is not linearizable — see {@link MpmcWorkQueue} for
+ * why claiming a place first makes that survivable, and {@link Queues#mpmcArrayQueue} for why it
+ * would not be otherwise.
+ */
+@Fork(1)
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@State(Scope.Benchmark)
+public class BackingOverheadBenchmark {
+
+ private static final int CAPACITY = 1024;
+ private static final String ELEMENT = "element";
+
+ private Queue clq1;
+ private Queue mpmc1;
+ private Queue clqN;
+ private Queue mpmcN;
+
+ @Setup
+ public void setUp() {
+ clq1 = new ConcurrentLinkedQueue<>();
+ mpmc1 = new MpmcArrayQueue<>(CAPACITY);
+ clqN = new ConcurrentLinkedQueue<>();
+ mpmcN = new MpmcArrayQueue<>(CAPACITY);
+ }
+
+ @Benchmark
+ @Threads(1)
+ public void clqSteady(Blackhole bh) {
+ bh.consume(clq1.offer(ELEMENT));
+ bh.consume(clq1.poll());
+ }
+
+ @Benchmark
+ @Threads(1)
+ public void mpmcSteady(Blackhole bh) {
+ bh.consume(mpmc1.offer(ELEMENT));
+ bh.consume(mpmc1.poll());
+ }
+
+ @Benchmark
+ @Threads(4)
+ public void clqContended(Blackhole bh) {
+ bh.consume(clqN.offer(ELEMENT));
+ bh.consume(clqN.poll());
+ }
+
+ @Benchmark
+ @Threads(4)
+ public void mpmcContended(Blackhole bh) {
+ bh.consume(mpmcN.offer(ELEMENT));
+ bh.consume(mpmcN.poll());
+ }
+}
diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/BatchAdmissionBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/BatchAdmissionBenchmark.java
new file mode 100644
index 00000000000..03d8d796933
--- /dev/null
+++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/BatchAdmissionBenchmark.java
@@ -0,0 +1,177 @@
+package datadog.common.queue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+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;
+import org.openjdk.jmh.infra.Blackhole;
+
+/**
+ * What one claim for a run of elements saves over one claim per element, with nothing else going
+ * on. The contended half of the question -- what the same batch costs the producers next to it --
+ * is {@link ContendedBatchAdmissionBenchmark}; this one is the ceiling on the saving.
+ *
+ *
+ * ./gradlew :utils:queue-utils:jmh -Pjmh.includes=BatchAdmission -Pjmh.profilers=gc
+ *
+ *
+ * Each arm admits {@code batchSize} elements and drains the same number, so the queue sits well
+ * off its bound and every admission succeeds. Timings are per call, which is per batch: divide by
+ * {@code batchSize} for the per-element figure the pair is actually comparing. The elements are
+ * built once in setup, so what is measured is admission and not construction.
+ *
+ *
What the saving can be. Admission costs a claim on the shared counter and a store into
+ * the backing. Batching removes all but one of the claims and none of the stores, and the MPSC
+ * ring's store is itself a compare-and-set on the producer index. So the ceiling here is a little
+ * under half the per-element cost on that backing, and rather more on the linked one, where the
+ * store is a node allocation and a CAS on the tail but the counter is the only bound there is.
+ *
+ *
Why the batch arm cannot simply win. It walks an iterator and refills a claim every
+ * {@code MAX_BATCH_CLAIM} elements, which the loop arm does not. The sizes run from 4 to 128 to
+ * find where that bookkeeping stops costing more than the claims it removes -- and the answer,
+ * below, is that at one thread it never does.
+ *
+ *
Results, and they go the other way. One fork, five iterations, JDK 25, on a machine
+ * with other work on it -- but the intervals here are tight (a percent or two), because there is no
+ * contention to amplify anything. The per-element columns are the call divided by {@code
+ * batchSize}.
+ *
+ *
+ * Benchmark (backings) (batchSize) ns/op ns/element
+ * batchOfElements MPSC 4 68.4 17.1
+ * loopOfElements MPSC 4 58.1 14.5
+ * batchOfElements MPSC 8 137.0 17.1
+ * loopOfElements MPSC 8 114.9 14.4
+ * batchOfElements MPSC 32 519.6 16.2
+ * loopOfElements MPSC 32 456.7 14.3
+ * batchOfElements MPSC 128 2084.0 16.3
+ * loopOfElements MPSC 128 1825.2 14.3
+ * batchOfElements MPMC 4 84.4 21.1
+ * loopOfElements MPMC 4 83.7 20.9
+ * batchOfElements MPMC 8 169.8 21.2
+ * loopOfElements MPMC 8 170.2 21.3
+ * batchOfElements MPMC 32 700.1 21.9
+ * loopOfElements MPMC 32 692.4 21.6
+ * batchOfElements MPMC 128 2785.1 21.8
+ * loopOfElements MPMC 128 2804.0 21.9
+ *
+ *
+ * Every arm allocates nothing measurable now that the multi-consumer backing is array-backed
+ * rather than linked. The MPMC rows above were within a nanosecond per element of these on the
+ * linked queue, which manufactured a node per element to get there -- 24 bytes an element, priced
+ * in {@link BackingOverheadBenchmark}. Time at parity, allocation gone, is the whole of what that
+ * swap bought; the 8ns per operation the structures differ by in isolation does not survive the
+ * admission machinery being laid on top of it.
+ *
+ *
The producer arms track the element arms to within a nanosecond per element at every size, on
+ * both backings, so they are left out of the table above; the claim is what they share and the
+ * producer call is inlined away.
+ *
+ *
Batching is slower here, by about 2ns per element on MPSC and not at all on MPMC. That
+ * is the honest ceiling on the saving, and it is negative: an uncontended atomic add is a few
+ * cycles, and removing 31 of them out of 32 does not pay for walking an iterator and refilling a
+ * claim. Some of that gap is the iterator itself rather than the claim bookkeeping -- the loop arm
+ * indexes an {@code ArrayList} and the batch arm cannot, because its argument is a {@code
+ * Collection}. It does not much matter which half it is: the size is the same either way and the
+ * sign does not change.
+ *
+ *
Nor does the sign change with batch size, which is the useful part. The saving does not turn
+ * positive at 128, so there is no crossover to find and no minimum batch size to recommend on this
+ * evidence. Whatever batching is worth, it is not worth anything to a producer that is alone.
+ *
+ *
Which is the point. The claim's cost is not in the instruction, it is in the line
+ * everyone else is reading -- and a benchmark with one thread has taken that out. Read this table
+ * as the floor and {@link ContendedBatchAdmissionBenchmark} as the case: there the same batching
+ * producer is between 1.3x and 3.3x faster than the same loop. A caller choosing between them
+ * should be asking how many threads share the queue, not how long its batches are.
+ */
+@Fork(2)
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 1)
+@Threads(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@State(Scope.Thread)
+public class BatchAdmissionBenchmark {
+
+ public enum Backings {
+ MPSC,
+ MPMC
+ }
+
+ /** Comfortably above the largest batch, so no arm is admitting at the bound. */
+ private static final int CAPACITY = 1024;
+
+ private static final String ELEMENT = "element";
+
+ /**
+ * Turns a source element into the queue's element without allocating, as a real one would not.
+ */
+ private static final BiContextualProducer PRODUCER =
+ (source, context) -> source;
+
+ @Param({"MPSC", "MPMC"})
+ public Backings backings;
+
+ @Param({"4", "8", "32", "128"})
+ public int batchSize;
+
+ private WorkQueue queue;
+
+ /** Built once: the arms compare admission, not the cost of producing a list. */
+ private List elements;
+
+ @Setup
+ public void setUp() {
+ queue =
+ backings == Backings.MPSC
+ ? WorkQueues.createMpscQueue(CAPACITY)
+ : WorkQueues.createMpmcQueue(CAPACITY);
+ elements = new ArrayList<>(batchSize);
+ for (int i = 0; i < batchSize; i++) {
+ elements.add(ELEMENT);
+ }
+ }
+
+ /** One claim per run of elements. */
+ @Benchmark
+ public void batchOfElements(Blackhole bh) {
+ bh.consume(queue.tryPutBatch(elements));
+ bh.consume(queue.process(batchSize, bh::consume));
+ }
+
+ /** The same admissions, one claim each: what a caller writes without the batch API. */
+ @Benchmark
+ public void loopOfElements(Blackhole bh) {
+ for (int i = 0; i < batchSize; i++) {
+ bh.consume(queue.tryPut(elements.get(i)));
+ }
+ bh.consume(queue.process(batchSize, bh::consume));
+ }
+
+ /** The producer form, where the claim also gates whether an element is built at all. */
+ @Benchmark
+ public void batchOfProducers(Blackhole bh) {
+ bh.consume(queue.tryPutBatch(elements, ELEMENT, PRODUCER));
+ bh.consume(queue.process(batchSize, bh::consume));
+ }
+
+ @Benchmark
+ public void loopOfProducers(Blackhole bh) {
+ for (int i = 0; i < batchSize; i++) {
+ bh.consume(queue.tryPut(elements.get(i), ELEMENT, PRODUCER));
+ }
+ bh.consume(queue.process(batchSize, bh::consume));
+ }
+}
diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedAdmissionBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedAdmissionBenchmark.java
new file mode 100644
index 00000000000..25bcbc78883
--- /dev/null
+++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedAdmissionBenchmark.java
@@ -0,0 +1,283 @@
+package datadog.common.queue;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import java.util.concurrent.TimeUnit;
+import org.jctools.queues.MpscArrayQueue;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+
+/**
+ * Admission with more than one thread arriving at once, which is the only way two of this module's
+ * central costs become visible at all.
+ *
+ *
+ * ./gradlew :utils:queue-utils:jmh -Pjmh.includes=ContendedAdmission -Pjmh.threads=8 -Pjmh.profilers=gc
+ *
+ *
+ * Allocation, as throughput. A single thread allocates almost for free — a pointer bump
+ * in a thread-local buffer — so a per-operation allocation shows up in {@code B/op} and barely at
+ * all in {@code ns/op}, which is how an allocation on a hot path gets waved through. Several
+ * threads allocating at once pay for it together: buffer refills, the memory bandwidth to touch
+ * fresh cache lines, and eventually collection. That turns the allocation into a throughput number,
+ * in the units a reviewer actually argues about. {@link AdmissionBenchmark} measured the
+ * reservation path at 0 B/op against 12 for a shared refusal singleton, and it measured that at one
+ * thread — where it is nearly free. This is where 12 B/op gets priced.
+ *
+ *
{@code refusedProducer} against {@code refusedBuildThenOffer} is this module's whole premise
+ * stated as a benchmark. Both sit on a full queue and admit nothing. The first hands over a
+ * producer and is never asked to build, because a place is claimed first and there was none; the
+ * second builds its element and then discovers there is no room, which is the shape {@link
+ * WorkQueue} exists to replace. At one thread the difference is an allocation that escape analysis
+ * may well erase anyway. Under load it is the difference the API is claiming.
+ *
+ *
Contention, as itself. {@link BaseWorkQueue#claimPlace} spends a place with one atomic
+ * decrement and gives it back with a second when there was none to spend, so a refused admission
+ * pays two read-modify-writes on one shared line — at the capacity boundary, where the most threads
+ * are arriving at once. {@code refusedRaw} is the baseline that prices it: on the MPSC backing
+ * jctools already enforces capacity through its own producer-index CAS, so a caller that never
+ * reserves is paying the counter for a bound it was getting free, and the delta is what that costs.
+ * That delta is what the relaxed read in {@code claimPlace} brought down from ~960ns to ~5ns. The
+ * linked backing has no such baseline — {@code ConcurrentLinkedQueue} is unbounded and the counter
+ * is the only thing bounding it, so there the comparison is against having no bound at all.
+ *
+ *
{@code steady} is the other half, and the commoner one: not full, with a consumer making room
+ * as fast as producers take it. It is the only arm where the counter is incremented by a drain
+ * while it is decremented by admission, contending on the same line from both directions. Its
+ * consumer is a thread this class owns rather than a {@code @Group} member; the arm's own note says
+ * why that distinction is load-bearing.
+ *
+ *
Neither cost is visible in {@link AdmissionBenchmark}, which is {@code @Threads(1)} and {@code
+ * Scope.Thread} — every thread there gets its own queue, so there is nothing to contend on and
+ * nothing to allocate alongside.
+ *
+ *
Results. Eight threads, one fork, {@code -Pjmh.profilers=gc}, JDK 25, on a machine with other
+ * work on it -- so the absolute numbers run high and the intervals are wide. They are an
+ * impression, not a baseline; {@code refusedRaw} is the control on every run.
+ *
+ *
The {@code before} column is the decrement-then-back-out admission this benchmark was written
+ * to price. The {@code after} column is the same run once {@link BaseWorkQueue#claimPlace} took to
+ * reading the count before spending from it.
+ *
+ *
+ * Benchmark (backings) before ns/op after ns/op B/op
+ * refusedProducer MPSC 1035.8 7.3 0
+ * refusedProducer MPMC 1162.5 7.2 0
+ * refusedQueue MPSC 963.7 7.2 0
+ * refusedQueue MPMC 1229.0 7.3 0
+ * refusedBuildThenOffer MPSC 448.9 212.7 32
+ * refusedBuildThenOffer MPMC 460.0 228.7 32
+ * refusedRaw MPSC 3.4 2.5 0
+ * refusedRaw MPMC 3.4 2.6 0
+ * steady MPSC 798.7 489.7 0
+ * steady MPMC 1008.4 252.8 0
+ *
+ *
+ * The {@code before} column predates two changes, not one: the MPMC rows were taken when that
+ * backing was a {@link java.util.concurrent.ConcurrentLinkedQueue} rather than an array ring. They
+ * are kept because the column exists to show the 120x, which is a property of the counter and not
+ * of the structure underneath it -- the refusal never reaches the backing at all. The {@code after}
+ * column is a single fresh run of everything, so the arms are comparable with each other.
+ *
+ *
What this measured. Two read-modify-writes on one shared line, taken by eight threads
+ * at the capacity boundary, cost about 120x what the same rejection costs when the first of them is
+ * a load instead. A refusal is now ~7.3ns against ~2.5ns for jctools' own producer-index CAS, so
+ * the permit counter costs on the order of 5ns over a bound the ring was already enforcing --
+ * against ~960ns before, where it dwarfed everything else the API does. {@code steady} moved with
+ * it, and for the same reason: eight producers against one drain thread keep the queue saturated,
+ * so most of that arm is refusals too.
+ *
+ *
Treat the ratio with more suspicion than the direction. Two contended read-modify-writes
+ * should not cost 960ns on a quiet machine -- tens of nanoseconds is the expected order -- so some
+ * of that baseline is this machine's other work amplifying the contention, threads losing their
+ * slice mid-sequence with the line hot. The ~7.9ns is tight and the mechanism is not in doubt; a
+ * quiet run will likely show a smaller multiple against a smaller before.
+ *
+ *
{@code steady} is the one arm where the two backings now separate, and by more than their
+ * intervals: 252.8ns against 489.7ns, the MPMC ring ahead of the MPSC one. Eight producers against
+ * a single drain thread is a shape the multi-consumer ring is built for and the single-consumer
+ * ring is not, and the gap is not evidence about admission -- read it as the drain, not the claim.
+ *
+ *
Attribution, since two changes landed together: this is the read, not the folding of the
+ * closed flag into the count. Removing a volatile boolean load cannot account for 950ns. Folding it
+ * was structural -- one word of state instead of two that have to agree.
+ *
+ *
The premise pair, which now goes the way the module argues. {@code refusedProducer}
+ * against {@code refusedBuildThenOffer} is reserve-before-build against building first and finding
+ * out after: ~8ns and 0 B/op against ~422ns and 32 B/op. Before the read it was the awkward result
+ * -- 0 B/op but slower in {@code ns/op} -- because the counter cost more than the allocation it
+ * avoided. It no longer does. Read the pair for what it is even so: the build-then-offer arm has no
+ * counter and the producer arm has no allocation, so it is not one variable. What it establishes is
+ * the ordering, and the ordering has reversed.
+ */
+@Fork(2)
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 1)
+@Threads(4)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@State(Scope.Benchmark)
+public class ContendedAdmissionBenchmark {
+
+ public enum Backings {
+ MPSC,
+ MPMC
+ }
+
+ /** Big enough that the steady arm is not living at the boundary by accident. */
+ private static final int CAPACITY = 1024;
+
+ private static final String ELEMENT = "element";
+
+ /**
+ * Stands in for the element a real producer builds — a client-stats {@code SpanSnapshot} and its
+ * tag arrays. Its size is not meant to be exact; what matters is that it is a real allocation
+ * with a stable footprint, so the arm that builds one can be compared against the arm that does
+ * not.
+ */
+ static final class Payload {
+ final Object a;
+ final Object b;
+ final long duration;
+ final int hash;
+
+ Payload(Object a, Object b, long duration) {
+ this.a = a;
+ this.b = b;
+ this.duration = duration;
+ this.hash = a.hashCode() ^ b.hashCode();
+ }
+ }
+
+ /** Allocates on every call, and is only ever called once a place is already claimed. */
+ private static final Producer BUILDER =
+ () -> new Payload(ELEMENT, ELEMENT, System.nanoTime());
+
+ @Param({"MPSC", "MPMC"})
+ public Backings backings;
+
+ /** Never full, drained concurrently by {@link #consume}. */
+ private WorkQueue queue;
+
+ /** Filled once in setup and never drained, so every admission is refused. */
+ private WorkQueue full;
+
+ /** The same, typed for the producer arm. */
+ private WorkQueue fullPayloads;
+
+ /**
+ * A full queue with no permit counter, for the two baselines. Unaffected by {@code backings} — it
+ * is the same number in both rows, and is what the MPSC backing would cost on the ring's own
+ * bound alone.
+ */
+ private MpscArrayQueue raw;
+
+ /** The one consumer for {@link #steady}. Owned here, not by JMH -- see that arm's note. */
+ private Thread drain;
+
+ private volatile boolean draining;
+
+ @Setup
+ public void setUp() {
+ queue = create(CAPACITY);
+ full = create(16);
+ while (full.tryPut(ELEMENT)) {
+ // fill it, so claimPlace always has to back out
+ }
+ fullPayloads =
+ backings == Backings.MPSC ? WorkQueues.createMpscQueue(16) : WorkQueues.createMpmcQueue(16);
+ while (fullPayloads.tryPut(new Payload(ELEMENT, ELEMENT, 0L))) {
+ // same, for the producer arm
+ }
+ raw = new MpscArrayQueue<>(16);
+ while (raw.offer(ELEMENT)) {
+ // same, through jctools' own rejection
+ }
+ draining = true;
+ drain =
+ new Thread(
+ () -> {
+ while (draining) {
+ // Not timed, and deliberately not throttled: the point is to keep the steady arm
+ // off the capacity boundary and to keep the counter's increment side busy.
+ queue.process(CAPACITY, e -> {});
+ }
+ },
+ "contended-admission-drain");
+ drain.setDaemon(true);
+ drain.start();
+ }
+
+ @TearDown
+ public void tearDown() throws InterruptedException {
+ draining = false;
+ drain.join(SECONDS.toMillis(5));
+ }
+
+ private WorkQueue create(int capacity) {
+ return backings == Backings.MPSC
+ ? WorkQueues.createMpscQueue(capacity)
+ : WorkQueues.createMpmcQueue(capacity);
+ }
+
+ /** Reserve-before-build: the producer is never asked, so nothing is allocated. */
+ @Benchmark
+ public void refusedProducer(Blackhole bh) {
+ bh.consume(fullPayloads.tryPut(BUILDER));
+ }
+
+ /**
+ * Build-then-offer: what the same rejection costs when the element is constructed before the
+ * queue gets a say. Consumed through the blackhole so it genuinely escapes, the way an element
+ * handed to a queue in another class does — otherwise escape analysis erases the allocation this
+ * arm exists to charge for.
+ */
+ @Benchmark
+ public void refusedBuildThenOffer(Blackhole bh) {
+ Payload payload = new Payload(ELEMENT, ELEMENT, System.nanoTime());
+ bh.consume(raw.offer(payload));
+ bh.consume(payload);
+ }
+
+ /** Two RMWs on the shared counter, every call, from every thread. */
+ @Benchmark
+ public void refusedQueue(Blackhole bh) {
+ bh.consume(full.tryPut(ELEMENT));
+ }
+
+ /** The bound jctools gives for free, for the delta. */
+ @Benchmark
+ public void refusedRaw(Blackhole bh) {
+ bh.consume(raw.offer(ELEMENT));
+ }
+
+ /**
+ * The commoner half: not full, with a consumer making room about as fast as producers take it, so
+ * the counter is incremented by a drain while it is decremented by admission -- contending on the
+ * same line from both directions.
+ *
+ * Every JMH thread is a producer here, and the consumer is the dedicated {@link #drain} thread
+ * started in setup rather than a {@code @Group} member. That is not a stylistic choice. A group's
+ * {@code @GroupThreads(1)} fixes the consumer count *per group*, and JMH instantiates as many
+ * groups as the thread count allows -- so {@code -Pjmh.threads=8} against a group of 4 yields two
+ * consumers. Two concurrent {@code poll()}s on the single-consumer MPSC ring do not fail; they
+ * spin inside jctools' gap-wait and the iteration never ends. Owning the consumer outright makes
+ * the arm correct at any thread count, which is what the project's spot-check flags hand it.
+ */
+ @Benchmark
+ public void steady(Blackhole bh) {
+ bh.consume(queue.tryPut(ELEMENT));
+ }
+}
diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedBatchAdmissionBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedBatchAdmissionBenchmark.java
new file mode 100644
index 00000000000..4685918ae66
--- /dev/null
+++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedBatchAdmissionBenchmark.java
@@ -0,0 +1,231 @@
+package datadog.common.queue;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.openjdk.jmh.annotations.AuxCounters;
+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.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+
+/**
+ * What a batching producer costs the producers admitting next to it, which is the half of batch
+ * claiming that its own throughput number cannot show. {@link BatchAdmissionBenchmark} measures
+ * what the batcher saves; this measures who pays for it.
+ *
+ *
+ * ./gradlew :utils:queue-utils:jmh -Pjmh.includes=ContendedBatchAdmission
+ *
+ *
+ * The mechanism being priced. A claim spends places by driving the shared count down and
+ * gives back what it could not use, and every other producer's admission opens by comparing that
+ * same count against zero. So while a claim of {@code n} is outstanding the count reads {@code n}
+ * lower than it is, and a neighbour arriving at a queue with fewer than {@code n} places left is
+ * turned away by a load -- correctly, in the sense that the places really were spoken for, and
+ * spuriously, in the sense that most of them are about to come back. That window is why {@link
+ * BaseWorkQueue} caps a claim rather than taking the whole batch at once.
+ *
+ *
The shape. Two groups of four threads, differing only in what the first thread does. In
+ * {@code batched} it admits {@code batchSize} elements with one {@code tryPutBatch}; in {@code
+ * unbatched} it admits the same elements with a loop of {@code tryPut}. The other three threads are
+ * identical in both: a single {@code tryPut} each, and they are the measurement. The comparison
+ * that answers the question is {@code neighbourOfBatch} against {@code neighbourOfLoop} -- same
+ * work, same thread count, same queue, and the only difference is how the fourth thread claims.
+ *
+ *
Read the neighbour arms as a pair and the producer arms as a pair; across the two pairs the
+ * units differ, because a batch call admits {@code batchSize} elements and a single call admits
+ * one.
+ *
+ *
What would count as too large a cap. If {@code neighbourOfBatch} is materially slower
+ * than {@code neighbourOfLoop}, or refuses materially more often, the cap is buying the batcher's
+ * throughput out of its neighbours' -- which is a trade a shared queue should not make quietly.
+ * {@code refused} is reported alongside for that reason: a neighbour turned away costs almost
+ * nothing in time and everything in outcome, so the timing alone would hide it.
+ *
+ *
The consumer is a thread this class owns rather than a group member, for the reason {@link
+ * ContendedAdmissionBenchmark#steady} sets out: a group's consumer count scales with the thread
+ * count, and two concurrent polls on the single-consumer MPSC ring hang inside jctools' gap-wait.
+ *
+ *
Results. Four threads, one fork, five iterations, JDK 25, on a machine with other work
+ * on it. The intervals are wide -- often half the mean -- so read the producer column for its
+ * direction and the neighbour columns with real suspicion. {@code refused%} is {@code refused} over
+ * {@code attempts}, both summed over the iteration.
+ *
+ *
+ * (backings) (batchSize) producer ns/op neighbour ns/op neighbour refused%
+ * MPSC 8 batched 1038.3 232.7 67.6
+ * MPSC 8 looping 2331.8 294.9 49.2
+ * MPSC 32 batched 1637.3 213.2 67.6
+ * MPSC 32 looping 7392.9 221.2 68.0
+ * MPMC 8 batched 705.1 170.7 81.4
+ * MPMC 8 looping 1479.7 185.6 79.7
+ * MPMC 32 batched 1389.4 186.9 78.5
+ * MPMC 32 looping 3894.4 121.0 89.1
+ *
+ *
+ * The producer column is the finding. Batching wins everywhere it is contended, by 2.1x
+ * at eight elements on MPMC and by 4.5x at thirty-two on MPSC, and the advantage grows with the
+ * batch -- the opposite of {@link BatchAdmissionBenchmark}, where the same code at one thread is
+ * about 2ns per element slower. That is the whole case for batch claiming stated in two tables: it
+ * buys nothing from the instruction it removes and a great deal from the cache line it stops
+ * touching.
+ *
+ *
The neighbour columns do not resolve. The timings move in opposite directions on the
+ * two backings and every gap sits inside its own interval. The refusal rates do have a shape --
+ * every neighbour refuses more often the faster its neighbour admits -- but that is not the claim
+ * window, or not only. One consumer bounds total admissions, so a producer that admits three times
+ * faster takes three times the share, and its neighbours find the queue full more often. That is
+ * the batcher succeeding, not the cap leaking.
+ *
+ *
Which means this benchmark does not yet separate the two effects it was built to tell apart: a
+ * neighbour refused because places are transiently claimed, and a neighbour refused because
+ * somebody else's work got in first. Distinguishing them wants a quiet machine and a consumer fast
+ * enough that the queue is not the bottleneck. Until then {@code MAX_BATCH_CLAIM} rests on the
+ * argument rather than the measurement -- the dip is real and bounded by the cap whether or not
+ * this run can see it.
+ */
+@Fork(2)
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@State(Scope.Benchmark)
+public class ContendedBatchAdmissionBenchmark {
+
+ public enum Backings {
+ MPSC,
+ MPMC
+ }
+
+ /**
+ * A neighbour turned away costs almost nothing in time and everything in outcome, so the timing
+ * alone would hide the effect this class exists to find -- a batch that makes its neighbours
+ * refuse looks, in {@code ns/op}, like a batch that costs them nothing.
+ */
+ @State(Scope.Thread)
+ @AuxCounters(AuxCounters.Type.EVENTS)
+ public static class Outcomes {
+ public long refused;
+
+ /**
+ * Reported alongside {@link #refused} because the counter is a sum, not a rate, and the two
+ * arms do not run the same number of operations -- a neighbour that got faster refused more
+ * times for that reason alone. The ratio is the comparable number.
+ */
+ public long attempts;
+
+ @Setup(Level.Iteration)
+ public void reset() {
+ refused = 0;
+ attempts = 0;
+ }
+ }
+
+ /**
+ * Deliberately not much larger than a batch. The whole effect lives at the boundary: a queue with
+ * room to spare absorbs an outstanding claim without any neighbour noticing.
+ */
+ private static final int CAPACITY = 256;
+
+ private static final String ELEMENT = "element";
+
+ @Param({"MPSC", "MPMC"})
+ public Backings backings;
+
+ @Param({"8", "32"})
+ public int batchSize;
+
+ private WorkQueue queue;
+
+ private List elements;
+
+ /** The one consumer. Owned here, not by JMH -- see the class note. */
+ private Thread drain;
+
+ private volatile boolean draining;
+
+ @Setup
+ public void setUp() {
+ queue =
+ backings == Backings.MPSC
+ ? WorkQueues.createMpscQueue(CAPACITY)
+ : WorkQueues.createMpmcQueue(CAPACITY);
+ elements = new ArrayList<>(batchSize);
+ for (int i = 0; i < batchSize; i++) {
+ elements.add(ELEMENT);
+ }
+ draining = true;
+ drain =
+ new Thread(
+ () -> {
+ while (draining) {
+ // Unthrottled: the arms are about who gets in, not about starving the queue.
+ queue.process(CAPACITY, e -> {});
+ }
+ },
+ "contended-batch-admission-drain");
+ drain.setDaemon(true);
+ drain.start();
+ }
+
+ @TearDown
+ public void tearDown() throws InterruptedException {
+ draining = false;
+ drain.join(SECONDS.toMillis(5));
+ }
+
+ /** One claim for the whole run, so the count dips by the claim's width until the rest is back. */
+ @Benchmark
+ @Group("batched")
+ @GroupThreads(1)
+ public void batchedProducer(Blackhole bh) {
+ bh.consume(queue.tryPutBatch(elements));
+ }
+
+ /** The measurement: an ordinary producer, alongside a batching one. */
+ @Benchmark
+ @Group("batched")
+ @GroupThreads(3)
+ public void neighbourOfBatch(Outcomes outcomes) {
+ outcomes.attempts++;
+ if (!queue.tryPut(ELEMENT)) {
+ outcomes.refused++;
+ }
+ }
+
+ /** The same admissions as {@link #batchedProducer}, one claim each: the control. */
+ @Benchmark
+ @Group("unbatched")
+ @GroupThreads(1)
+ public void loopingProducer(Blackhole bh) {
+ for (int i = 0; i < batchSize; i++) {
+ bh.consume(queue.tryPut(elements.get(i)));
+ }
+ }
+
+ /** The same measurement, with nothing batching beside it. */
+ @Benchmark
+ @Group("unbatched")
+ @GroupThreads(3)
+ public void neighbourOfLoop(Outcomes outcomes) {
+ outcomes.attempts++;
+ if (!queue.tryPut(ELEMENT)) {
+ outcomes.refused++;
+ }
+ }
+}
diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/RetryLeaseBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/RetryLeaseBenchmark.java
new file mode 100644
index 00000000000..6dd3a0fbfbc
--- /dev/null
+++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/RetryLeaseBenchmark.java
@@ -0,0 +1,277 @@
+package datadog.common.queue;
+
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+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;
+import org.openjdk.jmh.infra.Blackhole;
+
+/**
+ * What the retry lease costs, and whether it costs it only on the failure path.
+ *
+ * ./gradlew :utils:queue-utils:jmh -Pjmh.includes=RetryLease -Pjmh.profilers=gc
+ *
+ * {@code BaseWorkQueue.lease} allocates an anonymous {@link RetryQueue} per failure, capturing
+ * the queue and the attempt number. The alternative is one lease per queue held in a field, which
+ * allocates never -- but a field cannot carry a per-item attempt number without either a mutable
+ * field (a race the moment {@code createMpmcQueue} gives the queue a second consumer) or an API
+ * change putting {@code attempt} back into {@link RetryQueue#retry}. The question this answers is
+ * whether that trade is even on the table, or whether escape analysis already deletes the
+ * allocation.
+ *
+ *
The lease is handed to {@link RetryStrategy#onFailure}, which is user code behind an
+ * interface. C2 can only prove the lease does not escape by inlining {@code onFailure} and seeing
+ * what it does, so the answer should depend on how many strategy types the process has loaded --
+ * hence the {@code strategies} parameter, which loads one, two, or four.
+ *
+ *
{@code gc.alloc.rate.norm} is the number that answers it. The thrown exception is a
+ * preallocated, stackless singleton so that its own allocation does not swamp the lease's.
+ *
+ *
Results:
+ *
+ *
+ * gc.alloc.rate.norm, B/op ONE TWO FOUR ns/op
+ * succeeds 0 0 0 21.3
+ * failsAndGivesUp (static final) 0 0 0 22.8
+ * failsAndGivesUpInline (lambda) 0 0 0 22.4
+ * failsAndGivesUpCapturing 0 0 0 21.2
+ * failsAndGivesUpExactField 0 0 0 22.4
+ * failsAndGivesUpVirtual (iface field) 0 0 24 22.6
+ * failsAndRetries 24 24 24 41.6
+ * failsWithHandler (control) 0 0 0 22.5
+ *
+ *
+ * JDK 25, {@code -Pjmh.forks=2}. Three things are visible, and only the third was the one being
+ * looked for.
+ *
+ *
What the strategy is bound to decides everything. Five failure arms run the same policy
+ * and differ only in how the call site reaches it. Four cost nothing at any number of loaded
+ * implementations, by two different routes. Three of them make the value known: a {@code
+ * static final} field is a trusted constant; an inline non-capturing lambda links through a {@code
+ * ConstantCallSite} and folds to the same thing without a field to declare; an inline
+ * capturing lambda folds to nothing at all, but its allocation site sits in the caller, so
+ * the exact class is visible anyway and the capture scalar-replaces. The fourth makes the
+ * type known: {@code failsAndGivesUpExactField} reads a deliberately non-final field
+ * declared at a concrete final class, where every value it could hold has the same klass. Only
+ * {@code failsAndGivesUpVirtual}, an interface-typed field, has neither and must ask the receiver
+ * profile -- which at {@code FOUR} has nothing useful to say.
+ *
+ *
The two routes are not interchangeable, and which one is available depends on the situation
+ * rather than on taste. A per-instance policy cannot use the value route at all: folding {@code
+ * this.strategy} needs the holder to be a constant too, so no amount of finality helps an object
+ * allocated at runtime. It has only the type route -- which means a named class, because a lambda's
+ * class is unnameable and the tightest a field holding one can be declared is the abstract
+ * interface. A shared policy can use either. So: singleton policy, {@code static final} or an
+ * inline lambda; per-instance policy, a named final class with the field declared at it. The
+ * failure in this table is what trying to use the first shape for the second situation looks like.
+ *
+ *
Note also what {@code failsAndGivesUpCapturing} does not say. Its capture is free
+ * because everything around it inlined; store that same lambda into a field and read it back and
+ * both the capture and the devirtualization are gone.
+ *
+ *
The lease costs 24 bytes, in one shape. Those 24 bytes -- an object header, the
+ * captured queue, the captured attempt number -- appear only in the virtual arm at {@code FOUR},
+ * which is exactly where {@code onFailure} stopped inlining and C2 could no longer see that the
+ * lease does not escape. {@code failsWithHandler} takes the same throw down the {@link
+ * ExceptionHandler} branch, which is never handed a lease, and reads zero throughout, so the 24
+ * bytes are the lease rather than something else on the failure path.
+ *
+ *
Retrying costs 24 bytes always. {@code failsAndRetries} allocates at every type count,
+ * including where the lease is provably free. That is the {@code Retry} wrapper the re-admitted
+ * item is boxed in, not the lease, and only an item actually resubmitted pays it.
+ *
+ *
Timing says nothing either way; every arm that fails once is within noise of every other, and
+ * an allocation this size is invisible next to a throw.
+ */
+@Fork(2)
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 1)
+@Threads(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@State(Scope.Thread)
+public class RetryLeaseBenchmark {
+
+ public enum Strategies {
+ ONE,
+ TWO,
+ FOUR
+ }
+
+ private static final String ELEMENT = "element";
+
+ /** Stackless and shared, so the failure path's own allocation is not what gets measured. */
+ private static final RuntimeException FAILURE =
+ new RuntimeException("consumer failed", null, false, false) {};
+
+ private static final Consumer SUCCEEDS = item -> {};
+
+ private static final Consumer THROWS =
+ item -> {
+ throw FAILURE;
+ };
+
+ /** Gives up without touching the lease: the lease is allocated, passed, and never used. */
+ private static final RetryStrategy GIVE_UP = (item, attempt, failure, queue) -> false;
+
+ /** Uses the lease, which is what a strategy is handed one for. */
+ private static final RetryStrategy RETRY_ONCE =
+ (item, attempt, failure, queue) -> attempt <= 1 && queue.retry(item);
+
+ private static final ExceptionHandler HANDLER = (item, failure) -> {};
+
+ /**
+ * The same {@link #GIVE_UP} strategy, reached through a non-final instance field instead of a
+ * {@code static final} one. Only the binding differs, and it is the whole experiment: a constant
+ * strategy tells C2 the exact receiver type outright, so {@code onFailure} devirtualizes by
+ * static resolution no matter how many strategy types the process has loaded. Reading it from a
+ * field takes that away and leaves the shared receiver profile as the only evidence C2 has.
+ */
+ private RetryStrategy bound;
+
+ /** Two more strategy types, loaded to move the {@code onFailure} call site off monomorphic. */
+ private static final RetryStrategy THIRD =
+ new RetryStrategy() {
+ @Override
+ public boolean onFailure(String i, int attempt, Throwable f, RetryQueue q) {
+ return false;
+ }
+ };
+
+ private static final RetryStrategy FOURTH =
+ new RetryStrategy() {
+ @Override
+ public boolean onFailure(String i, int attempt, Throwable f, RetryQueue q) {
+ return false;
+ }
+ };
+
+ @Param({"ONE", "TWO", "FOUR"})
+ public Strategies strategies;
+
+ private WorkQueue queue;
+
+ private WorkQueue other;
+
+ /**
+ * The same policy as {@link #GIVE_UP}, written as a named final class so a field can be declared
+ * at its exact type. A lambda cannot be used here: its class is unnameable, so the tightest a
+ * field holding one can be declared is the functional interface.
+ */
+ static final class GiveUp implements RetryStrategy {
+ @Override
+ public boolean onFailure(String i, int attempt, Throwable f, RetryQueue q) {
+ return false;
+ }
+ }
+
+ /**
+ * Deliberately not {@code final}: the point of the arm is that the declared type carries
+ * the answer. {@link GiveUp} is a final class, so every value this field can hold has exactly
+ * that klass, and C2 resolves {@code onFailure} off the type without needing to trust the value.
+ */
+ private GiveUp exact;
+
+ /** Captured by {@link #failsAndGivesUpCapturing}, so that lambda cannot be hoisted. */
+ private int mixer;
+
+ @Setup
+ public void setUp(Blackhole bh) {
+ queue = WorkQueues.createMpscQueue(1024);
+ bound = GIVE_UP;
+ exact = new GiveUp();
+ other = WorkQueues.createMpscQueue(1024);
+ // Drive the extra strategy types through the same onFailure site the measured loop uses, so
+ // the only thing the parameter varies is how many types that site has seen.
+ for (int i = 0; i < 50_000; i++) {
+ if (strategies != Strategies.ONE) {
+ other.tryPut(ELEMENT);
+ other.processOrRetry(THROWS, THIRD);
+ }
+ if (strategies == Strategies.FOUR) {
+ other.tryPut(ELEMENT);
+ other.processOrRetry(THROWS, FOURTH);
+ other.tryPut(ELEMENT);
+ other.processOrRetry(THROWS, RETRY_ONCE);
+ other.process(bh::consume);
+ }
+ }
+ }
+
+ @Benchmark
+ public boolean succeeds() {
+ queue.tryPut(ELEMENT);
+ return queue.processOrRetry(SUCCEEDS, GIVE_UP);
+ }
+
+ @Benchmark
+ public boolean failsAndGivesUp() {
+ queue.tryPut(ELEMENT);
+ return queue.processOrRetry(THROWS, GIVE_UP);
+ }
+
+ /** Retried once, then given up on, so the queue is empty again at the end of every invocation. */
+ @Benchmark
+ public boolean failsAndRetries() {
+ queue.tryPut(ELEMENT);
+ boolean first = queue.processOrRetry(THROWS, RETRY_ONCE);
+ return first & queue.processOrRetry(THROWS, RETRY_ONCE);
+ }
+
+ @Benchmark
+ public boolean failsAndGivesUpVirtual() {
+ queue.tryPut(ELEMENT);
+ return queue.processOrRetry(THROWS, bound);
+ }
+
+ /**
+ * The same strategy written at the call site instead of stored anywhere. A non-capturing lambda
+ * links through a {@code ConstantCallSite}, so the {@code invokedynamic} folds to a constant oop
+ * of exact type -- the same thing a {@code static final} field gives C2, without the field.
+ */
+ @Benchmark
+ public boolean failsAndGivesUpInline() {
+ queue.tryPut(ELEMENT);
+ return queue.processOrRetry(THROWS, (item, attempt, failure, q) -> false);
+ }
+
+ /**
+ * The same lambda made capturing, which is the mistake the inline form invites. It has to be
+ * built per call, so nothing folds to a constant -- but the allocation site is still exactly
+ * typed, so how much that costs is a separate question from devirtualization.
+ */
+ @Benchmark
+ public boolean failsAndGivesUpCapturing() {
+ queue.tryPut(ELEMENT);
+ int floor = mixer++;
+ return queue.processOrRetry(THROWS, (item, attempt, failure, q) -> attempt < floor);
+ }
+
+ /**
+ * A field again, but declared at a concrete final class instead of the interface -- the shape
+ * {@link #failsAndGivesUpVirtual} is missing, and the only way a per-instance strategy can be
+ * devirtualized, since an instance field's value is never a trusted constant.
+ */
+ @Benchmark
+ public boolean failsAndGivesUpExactField() {
+ queue.tryPut(ELEMENT);
+ return queue.processOrRetry(THROWS, exact);
+ }
+
+ /** The control: the same throw down the handler branch, which is never handed a lease. */
+ @Benchmark
+ public boolean failsWithHandler() {
+ queue.tryPut(ELEMENT);
+ return queue.processOrHandle(THROWS, HANDLER);
+ }
+}
diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/ThirdBackingWorkQueue.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/ThirdBackingWorkQueue.java
new file mode 100644
index 00000000000..082c70fea3e
--- /dev/null
+++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/ThirdBackingWorkQueue.java
@@ -0,0 +1,36 @@
+package datadog.common.queue;
+
+import org.jctools.queues.MessagePassingQueue;
+
+/**
+ * A third concrete {@link BaseWorkQueue}, existing only so a benchmark can put three types into the
+ * profile of {@code store} and {@code retrieve}.
+ *
+ * It is a real backing rather than a stub — an SPSC ring, driven from one thread — because a
+ * stub whose {@code store} folded away would measure the dispatch and not the cliff. Kept in the
+ * benchmark source set: the claim in {@link BaseWorkQueue#store} is about what a third backing
+ * would cost, and shipping one to prove it would make the claim false.
+ */
+final class ThirdBackingWorkQueue extends BaseWorkQueue {
+
+ private final MessagePassingQueue queue;
+
+ ThirdBackingWorkQueue(int requestedCapacity) {
+ this(Queues.spscArrayQueue(requestedCapacity));
+ }
+
+ private ThirdBackingWorkQueue(MessagePassingQueue queue) {
+ super(queue.capacity());
+ this.queue = queue;
+ }
+
+ @Override
+ boolean store(Object element) {
+ return queue.offer(element);
+ }
+
+ @Override
+ Object retrieve() {
+ return queue.poll();
+ }
+}
diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java
new file mode 100644
index 00000000000..18048780bd8
--- /dev/null
+++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java
@@ -0,0 +1,870 @@
+package datadog.common.queue;
+
+import static java.util.Collections.emptyList;
+
+import datadog.trace.api.function.Strategy;
+import datadog.trace.api.function.StrategyConsumer;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+/**
+ * Everything a {@link WorkQueue} does that does not depend on how elements are stored: the bound,
+ * admission, reservations, the closed state, and the consume-and-maybe-retry cycle.
+ *
+ * Subclasses supply two storage primitives, {@link #store} and {@link #retrieve}, and neither
+ * needs to enforce anything. The bound lives here, as a count of places still available: admission
+ * spends one before it builds or stores anything, consumption returns one, and a reservation is
+ * simply a spent place with nothing in it yet. That is why the storage primitives can be as thin as
+ * they are, and why both backings admit and reserve through exactly the same code.
+ *
+ *
The counter costs one atomic add per admission and one per consumption. On a backing that
+ * could have leaned on its own bound that is a real tax, paid for a uniform contract: every backing
+ * can reserve capacity, nothing has to hold a position open, so no consumer can be stalled by a
+ * reservation and no reservation can deadlock a thread that also consumes.
+ */
+abstract class BaseWorkQueue implements WorkQueue {
+
+ /**
+ * Wraps an item that has already failed, carrying its attempt count back into the queue. Only
+ * allocated on the failure path, so the common case stores the element itself.
+ */
+ private static final class Retry {
+ final T item;
+ final int attempt;
+
+ Retry(T item, int attempt) {
+ this.item = item;
+ this.attempt = attempt;
+ }
+ }
+
+ /** Non-capturing adapters, so the producer forms share one admission path without allocating. */
+ private static final ContextualProducer, Object> PRODUCE = Producer::produce;
+
+ /**
+ * Subtracted from {@link #state} once, by {@link #close()}. Closing then costs no flag of its
+ * own: it drives the permit count so far negative that no claim can ever succeed again, so a
+ * closed queue refuses through the same comparison that a full one does and admission has one
+ * word to read instead of two that have to agree.
+ *
+ * Large enough to be unreachable from either side. Permits start at no more than {@link
+ * Integer#MAX_VALUE} and only places actually claimed are ever given back, so releases after a
+ * close cannot climb the offset; and the count is a {@code long} precisely because an unbounded
+ * queue seeds it with {@code Integer.MAX_VALUE}, which leaves an {@code int} no room above the
+ * bound to put this.
+ */
+ private static final long CLOSED_OFFSET = 1L << 40;
+
+ /** Any state below this has {@link #CLOSED_OFFSET} applied to it, and nothing else can be. */
+ private static final long CLOSED_MARK = -(1L << 39);
+
+ /**
+ * The most places one call will claim at a time, however long the batch.
+ *
+ *
This is not a fairness knob. A claim drives the shared count down by its own size until the
+ * unused part comes back, and every other producer's admission begins with a comparison of that
+ * count against zero -- so an oversized claim turns concurrent single admissions away for the
+ * width of the claim, at the boundary, where the most threads are arriving. The cap is a bound on
+ * how many neighbours one batching producer can make refuse, which is why it is modest rather
+ * than as large as batches get. Claims are also clamped to what the count says is available, so
+ * this bounds the dip a claim takes deliberately; the rest is the race the single claim already
+ * runs. {@code ContendedAdmissionBenchmark} prices both halves: what batching saves the batcher,
+ * and what it costs the producers next to it.
+ */
+ private static final int MAX_BATCH_CLAIM = 32;
+
+ /**
+ * Places still available, not places used, biased by {@link #CLOSED_OFFSET} once closed. The
+ * bound is then a comparison against zero rather than against a capacity that has to be loaded
+ * and that an unbounded queue has to be branched around: seeded with {@link Integer#MAX_VALUE} it
+ * is a queue no backlog can exhaust, on the same code path as any other.
+ */
+ private final AtomicLong state;
+
+ private final int capacity;
+
+ BaseWorkQueue(int capacity) {
+ this.capacity = capacity;
+ this.state = new AtomicLong(capacity);
+ }
+
+ /** The places left, with the closed bias taken back off. */
+ private static long permits(long state) {
+ return state < CLOSED_MARK ? state + CLOSED_OFFSET : state;
+ }
+
+ /**
+ * Stores an element in a place already claimed for it, so this can only fail if the backing
+ * refuses for a reason of its own.
+ *
+ *
A place has already been claimed, so a backing that can refuse transiently is expected to
+ * retry rather than report the refusal — a {@code false} from here loses the element, and there
+ * is no way for the queue to tell a structure saying "full" from one saying "not yet". {@link
+ * MpmcWorkQueue} is the case that matters.
+ *
+ *
What a third backing would cost here
+ *
+ * This is one call site shared by every backing in the process, so its receiver profile is
+ * global rather than per-caller: a queue used nowhere near yours still writes into it. At one or
+ * two implementations C2 inlines through it; at three it stops, and {@code -XX:+PrintInlining}
+ * reports {@code store} and {@code retrieve} as {@code failed to inline: virtual call} on both
+ * JDK 17 and JDK 25. So the cliff is real and it is easy to fall off — a third backing is a
+ * decision about every existing caller, not only about its own.
+ *
+ *
It is also, measured, worth one or two nanoseconds. {@code AdmissionBenchmark}'s {@code
+ * THREE} arm is the standing version of that experiment: 20.8ns against 21.8ns per
+ * admit-and-drain for {@code tryPut(Producer)}, the same shape across the other admission forms,
+ * and no allocation difference at all. The reason is that an admit-and-drain pays two uncontended
+ * atomics on the permit count and a compare-and-set inside the ring, and an out-of-line call is
+ * little against memory ordering. Contention widens the atomics and narrows this further, and
+ * batching does not change it either, because the drain still returns a place per element.
+ *
+ *
One path is worse: admitting through a {@link Reservation} costs about 30% more, because
+ * {@link Reservation#fill}'s {@code store} sits at the end of a chain of optimizations that has
+ * to survive a call that no longer folds away. The reservation is still scalar-replaced, so it is
+ * time and not garbage — but a caller admitting that way is the one with something to lose.
+ *
+ *
Which is the useful form of the warning. Do not add a third backing to buy throughput,
+ * because there is none here to buy; weigh it against a few percent and against the reader. And
+ * if a path ever admits without touching an atomic, measure again there, because that is where
+ * this would start to matter. The repair, if it comes to it, is to stop sharing the site: let
+ * each backing implement the public interface and forward into this class, so the receiver is an
+ * exact final type at the top of the inlining tree and the call devirtualizes by static
+ * resolution rather than by profile.
+ *
+ *
None of that applies to a backing that holds two structures behind one type, which is
+ * why {@link MpmcWorkQueue} branches on a field of its own rather than arriving here as two
+ * classes. A predictable branch costs nothing and adds no receiver.
+ *
+ * @return whether the element was stored
+ */
+ abstract boolean store(Object element);
+
+ /**
+ * @return the next stored object, or {@code null} if there was none
+ */
+ abstract Object retrieve();
+
+ /**
+ * How many times {@link #discardAll} will re-read a backing that says empty while the count says
+ * otherwise, before it believes the backing.
+ */
+ private static final int DRAIN_ATTEMPTS = 64;
+
+ /**
+ * Spends a place, and gives it back if there was none to spend, rather than looping on a
+ * compare-and-set. Admission costs one atomic add, with a second only on the path that was going
+ * to be rejected anyway — and no retry under contention, which is where a CAS loop is at its
+ * worst.
+ *
+ *
A plain read comes first, and it is what makes a refusal cheap. Without it a rejected
+ * admission paid two read-modify-writes on the one line every producer is already fighting over,
+ * at the capacity boundary, which is exactly where the most threads arrive at once -- {@code
+ * ContendedAdmissionBenchmark} priced that at roughly 960ns against 3.4ns for the same rejection
+ * taken on the backing's own producer index. A queue that is full, or closed, now turns a
+ * claimant away with a load. The decrement stays authoritative, so the bound is unaffected: the
+ * read can only cause a refusal, never an admission.
+ *
+ *
The bound itself is exact: the queue never holds more than {@code capacity} elements and
+ * open reservations together. What is approximate is who gets turned away. Claimants racing at
+ * the boundary can drive the count below zero between them and all give their places back, so an
+ * admission can be rejected while the queue is a place or two short of full. That only happens
+ * when it is already at the boundary, where the caller is dropping work regardless.
+ */
+ private boolean claimPlace() {
+ if (state.get() < 1) {
+ return false;
+ }
+ if (state.decrementAndGet() >= 0) {
+ return true;
+ }
+ state.incrementAndGet();
+ return false;
+ }
+
+ private void releasePlace() {
+ state.incrementAndGet();
+ }
+
+ /**
+ * Spends up to {@code wanted} places at once, and grants what was there rather than refusing
+ * because all of it was not. {@link #claimPlace} is this sequence with {@code wanted} of one.
+ *
+ *
A batch that claimed all-or-nothing would refuse the whole call when one place was short,
+ * which is a refusal the caller cannot distinguish from a full queue and cannot act on -- it had
+ * room for all but one. Granting the shortfall exactly removes that outcome by construction,
+ * without a retry at a smaller size: the initial read already says how large the claim can
+ * usefully be, so the scaling down is a clamp against a load this path was paying anyway, not a
+ * second trip.
+ *
+ *
The shape is {@link #claimPlace}'s, and deliberately so: one atomic add on the common path,
+ * a second only where the count went negative, and no compare-and-set loop. It reads {@link
+ * #state} raw rather than through {@link #permits}, for the same reason that one does -- a closed
+ * queue is biased far below zero, so it fails the first comparison and is turned away by a load,
+ * and a close landing mid-claim drives the count so negative that the whole claim is given back.
+ *
+ *
What a short grant means is therefore narrower than it looks. It is not proof the queue is
+ * full: concurrent claimants can each back out and leave places neither of them took. Callers
+ * loop until a claim comes back empty rather than treating the first short grant as the end.
+ *
+ * @return the places granted, between zero and {@code wanted}
+ */
+ private int claimPlaces(int wanted) {
+ long available = state.get();
+ if (available < 1) {
+ return 0;
+ }
+ int ask = (int) Math.min(wanted, available);
+ long after = state.addAndGet(-ask);
+ if (after >= 0) {
+ return ask;
+ }
+ // Short by exactly the deficit -- and if a close landed in between, the deficit is the whole
+ // claim, so the offset is restored untouched.
+ int give = (int) Math.min(-after, ask);
+ state.addAndGet(give);
+ return ask - give;
+ }
+
+ private void releasePlaces(int places) {
+ if (places != 0) {
+ state.addAndGet(places);
+ }
+ }
+
+ /**
+ * Shared with the retry path, where a refusal is a step rather than an outcome: a strategy handed
+ * a refused retry may still place the item somewhere else, and only its {@link
+ * RetryStrategy#onFailure} return says whether the item was finally lost.
+ */
+ private boolean admit(Object element) {
+ if (!claimPlace()) {
+ return false;
+ }
+ if (store(element)) {
+ return true;
+ }
+ releasePlace();
+ return false;
+ }
+
+ @StrategyConsumer
+ private boolean admit(
+ C context, @Strategy ContextualProducer super C, ? extends T> producer) {
+ if (!claimPlace()) {
+ return false;
+ }
+ T element;
+ try {
+ element = producer.produce(context);
+ } catch (Throwable t) {
+ releasePlace();
+ throw t;
+ }
+ return storeOrRelease(element);
+ }
+
+ @StrategyConsumer
+ private boolean admit(
+ C1 first,
+ C2 second,
+ @Strategy BiContextualProducer super C1, ? super C2, ? extends T> producer) {
+ if (!claimPlace()) {
+ return false;
+ }
+ T element;
+ try {
+ element = producer.produce(first, second);
+ } catch (Throwable t) {
+ releasePlace();
+ throw t;
+ }
+ return storeOrRelease(element);
+ }
+
+ /**
+ * The per-source-element half of {@link #tryPutBatch(Collection, Object, BiContextualProducer)},
+ * entered with a place already claimed for this element by the batch's own claim.
+ *
+ * Three outcomes, two of which report as not admitted for different reasons. A decline is the
+ * caller's own decision, and gives its place back. A backing that would not take what was
+ * produced is a refusal, and gives its place back too, but is reported to {@link RejectHandler}
+ * where a decline is not. The third way to fail -- no place at all -- cannot arise here, because
+ * the caller does not enter without one.
+ *
+ * @return whether the source element was admitted
+ */
+ @StrategyConsumer
+ private boolean admitEachClaimed(
+ E element,
+ C context,
+ @Strategy BiContextualProducer super E, ? super C, ? extends T> producer,
+ @Strategy RejectHandler super E> onRejected) {
+ T produced;
+ try {
+ produced = producer.produce(element, context);
+ } catch (Throwable t) {
+ releasePlace();
+ throw t;
+ }
+ if (produced == null) {
+ // Declined. The place goes back, and no handler is told, because nothing was lost.
+ releasePlace();
+ return false;
+ }
+ if (store(produced)) {
+ return true;
+ }
+ releasePlace();
+ reject(element, onRejected);
+ return false;
+ }
+
+ /** {@code null} rather than a no-op handler, so the count-only form adds a test and no call. */
+ @StrategyConsumer
+ private void reject(E element, @Strategy RejectHandler super E> onRejected) {
+ if (onRejected != null) {
+ onRejected.onRejected(element);
+ }
+ }
+
+ /**
+ * The tail of every producer admission. A {@code null} is the producer declining, which is the
+ * caller's own decision, so the place goes back and nothing was lost. A backing that would not
+ * take what was produced is a refusal. Both report as not admitted. Same three outcomes as {@link
+ * #admitEachClaimed}, which walks a source instead of taking one element.
+ */
+ private boolean storeOrRelease(T element) {
+ if (element == null) {
+ releasePlace();
+ return false;
+ }
+ if (store(element)) {
+ return true;
+ }
+ releasePlace();
+ return false;
+ }
+
+ /**
+ * A place spent ahead of the element that will use it. Filling can only ever store, because the
+ * room was already taken; abandoning gives the room back. Nothing is held open in the backing, so
+ * a consumer never has to wait on one.
+ *
+ * Both outcomes come from one allocation site. A refusal could be a shared singleton, and that
+ * is the more obvious design: it saves the allocation on the path that already lost. What it
+ * costs is paid by a caller that sees both outcomes at one site. Returning either a fresh
+ * reservation or a static merges an allocation with a globally reachable reference at a phi, and
+ * escape analysis gives up on the merge, so a reservation that would have been scalar-replaced
+ * away is allocated for real — {@code AdmissionBenchmark.reserveMixed} measures 12 bytes per call
+ * that way and zero this way, on JDK 17. JDK 21's allocation-merge support does not rescue it:
+ * that covers merges of non-escaping allocations and null, never a static.
+ *
+ *
The condition matters, because it is not every caller. A site that only ever sees one
+ * outcome — a queue that is effectively always accepting, or the drain loop's always-full
+ * counterpart — has its other branch pruned, and there is no merge left to defeat anything; both
+ * designs measure zero there. So this is insurance for the caller sitting at the capacity
+ * boundary rather than a saving for everyone. It is free insurance, which is the reason to take
+ * it: one allocation site is no worse anywhere, and it also keeps {@link #fill} and {@link
+ * #close} monomorphic for callers that never see a refusal, and drops an unchecked cast.
+ *
+ *
A refused reservation starts out {@code done}, which is what makes it inert: there is no
+ * place to give back and nothing to store, and both methods already short-circuit on that flag.
+ *
+ *
Static, with the queue handed in, rather than an inner class holding it implicitly. The
+ * reference is a field of this object either way, so nothing changes at runtime; what changes is
+ * that a reader can see it. That matters here more than it usually would, because the shape above
+ * is asking escape analysis to delete this object and promote its fields to locals — so the field
+ * count is the subject, and a hidden field is a hidden part of the subject.
+ */
+ private static final class PlaceReservation implements Reservation {
+ private final BaseWorkQueue queue;
+ private final boolean granted;
+ private boolean done;
+
+ PlaceReservation(BaseWorkQueue queue, boolean granted) {
+ this.queue = queue;
+ this.granted = granted;
+ this.done = !granted;
+ }
+
+ @Override
+ public boolean granted() {
+ return granted;
+ }
+
+ @Override
+ public void fill(T element) {
+ // Before the null check, so that filling a refusal stays silent: a caller that skipped
+ // building an element has nothing but null to offer, and the refused path never throws.
+ if (done) {
+ return;
+ }
+ requireElement(element);
+ done = true;
+ // Through the same tail as every other admission, and not a bare store: a backing is allowed
+ // to give up -- MpmcWorkQueue does, past its retry bound -- and a place spent on an element
+ // the backing would not take has to come back exactly once. Discarding this return was
+ // safe only while no backing could refuse an element it had already claimed room for.
+ queue.storeOrRelease(element);
+ }
+
+ @Override
+ public void close() {
+ // Only the reserving thread fills or closes, so a plain flag orders the two correctly.
+ if (!done) {
+ done = true;
+ queue.releasePlace();
+ }
+ }
+ }
+
+ private Object take() {
+ Object element = retrieve();
+ if (element != null) {
+ releasePlace();
+ }
+ return element;
+ }
+
+ /**
+ * Empties the queue, giving every place back as it goes.
+ *
+ * An empty read is not taken at face value while {@link #size} still reports work, because the
+ * two can disagree honestly: {@code size} counts places claimed by producers that have not stored
+ * into the backing yet, so an element on its way in is owed to this drain and is not visible to
+ * it. Stopping at the first empty read would leave that element behind holding its place, which
+ * for {@link #clear} and {@link #shutdown} is the difference between emptying the queue and
+ * appearing to. The re-read is bounded for the same reason it is needed: the producer whose place
+ * this is may be descheduled, or may never store at all, and would otherwise keep this loop here
+ * forever.
+ */
+ private void discardAll() {
+ int emptyReads = 0;
+ while (true) {
+ if (take() != null) {
+ emptyReads = 0;
+ } else if (size() <= 0 || ++emptyReads >= DRAIN_ATTEMPTS) {
+ return;
+ }
+ }
+ }
+
+ @Override
+ public final int size() {
+ // Claimants at the boundary can transiently drive the permit count out of range in either
+ // direction -- below zero before they back out, and above the count anyone was granted -- so
+ // the report is clamped rather than allowed to say more than capacity or less than nothing.
+ return (int) Math.min(capacity, Math.max(0, capacity - permits(state.get())));
+ }
+
+ @Override
+ public final boolean tryPut(T element) {
+ requireElement(element);
+ return admit(element);
+ }
+
+ @Override
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ public final boolean tryPut(Producer extends T> producer) {
+ return admit(producer, (ContextualProducer) PRODUCE);
+ }
+
+ @Override
+ public final boolean tryPut(C context, ContextualProducer super C, ? extends T> producer) {
+ return admit(context, producer);
+ }
+
+ @Override
+ public final boolean tryPut(
+ C1 first, C2 second, BiContextualProducer super C1, ? super C2, ? extends T> producer) {
+ return admit(first, second, producer);
+ }
+
+ /**
+ * One claim for a run of elements rather than one per element, which is what makes a batch worth
+ * calling rather than a loop the caller could have written.
+ *
+ * The loop stops on an empty claim, not on a short one. A short grant is not evidence the
+ * queue is full -- see {@link #claimPlaces} -- so treating it as the end would drop elements
+ * there was room for, which is the failure this shape exists to avoid.
+ *
+ *
What it does not remove is the backing's own per-element cost: a claimed place still has to
+ * be stored, and the MPSC ring charges a producer-index compare-and-set for each one. This halves
+ * the atomics per element on that backing, near enough; it does not get to one.
+ */
+ @Override
+ @SafeVarargs
+ public final Collection tryPutBatch(T... elements) {
+ List rejected = null;
+ int index = 0;
+ int length = elements.length;
+ while (index < length) {
+ int granted = claimPlaces(Math.min(length - index, MAX_BATCH_CLAIM));
+ if (granted == 0) {
+ // Refusals run to the end far more often than not: once the queue is full it stays full
+ // for the rest of the pass unless a consumer intervenes. Sizing for the remainder is an
+ // exact fit in that case and an over-fit in the other, and either beats regrowing.
+ if (rejected == null) {
+ rejected = new ArrayList<>(length - index);
+ }
+ for (; index < length; index++) {
+ T element = elements[index];
+ requireElement(element);
+ rejected.add(element);
+ }
+ break;
+ }
+ int end = index + granted;
+ try {
+ for (; index < end; index++) {
+ T element = elements[index];
+ requireElement(element);
+ if (!store(element)) {
+ releasePlace();
+ if (rejected == null) {
+ rejected = new ArrayList<>(length - index);
+ }
+ rejected.add(element);
+ }
+ }
+ } catch (Throwable t) {
+ // A null element throws out of the batch. The places claimed for it and for everything
+ // after it in this run were never spent, and have to go back before the throw leaves.
+ releasePlaces(end - index);
+ throw t;
+ }
+ }
+ return rejected == null ? emptyList() : rejected;
+ }
+
+ /** As {@link #tryPutBatch(Object[])}, over a collection. */
+ @Override
+ public final Collection tryPutBatch(Collection extends T> elements) {
+ List rejected = null;
+ int remaining = elements.size();
+ Iterator extends T> source = elements.iterator();
+ while (remaining > 0) {
+ int granted = claimPlaces(Math.min(remaining, MAX_BATCH_CLAIM));
+ if (granted == 0) {
+ if (rejected == null) {
+ rejected = new ArrayList<>(remaining);
+ }
+ while (source.hasNext()) {
+ T element = source.next();
+ requireElement(element);
+ rejected.add(element);
+ }
+ break;
+ }
+ int unspent = granted;
+ try {
+ while (unspent > 0 && source.hasNext()) {
+ T element = source.next();
+ requireElement(element);
+ unspent--;
+ remaining--;
+ if (!store(element)) {
+ releasePlace();
+ if (rejected == null) {
+ rejected = new ArrayList<>(remaining + 1);
+ }
+ rejected.add(element);
+ }
+ }
+ } catch (Throwable t) {
+ releasePlaces(unspent);
+ throw t;
+ }
+ if (unspent > 0) {
+ // The collection ran out before the claim did -- size() lied, or lies under concurrent
+ // modification. Give back what was never spent rather than losing it to the count.
+ releasePlaces(unspent);
+ break;
+ }
+ }
+ return rejected == null ? emptyList() : rejected;
+ }
+
+ @Override
+ public final int tryPutBatch(
+ Collection extends E> source,
+ C context,
+ BiContextualProducer super E, ? super C, ? extends T> producer) {
+ return tryPutBatch(source, context, producer, null);
+ }
+
+ /**
+ * As {@link #tryPutBatch(Object[])}, asking a producer for each element that already has a place.
+ *
+ * A declined element gives its place straight back to the count, so it does not consume the
+ * batch's room -- but it does consume this run's claim, which is why the loop keeps claiming
+ * until one comes back empty rather than stopping at the first run that did not fill.
+ */
+ @Override
+ public final int tryPutBatch(
+ Collection extends E> source,
+ C context,
+ BiContextualProducer super E, ? super C, ? extends T> producer,
+ RejectHandler super E> onRejected) {
+ int admitted = 0;
+ int remaining = source.size();
+ Iterator extends E> elements = source.iterator();
+ while (remaining > 0) {
+ int granted = claimPlaces(Math.min(remaining, MAX_BATCH_CLAIM));
+ if (granted == 0) {
+ // No place, so the producer is never asked -- the whole point of the API, kept at the end
+ // of a batch as well as at the start of one.
+ while (elements.hasNext()) {
+ reject(elements.next(), onRejected);
+ }
+ break;
+ }
+ int unspent = granted;
+ try {
+ while (unspent > 0 && elements.hasNext()) {
+ E element = elements.next();
+ unspent--;
+ remaining--;
+ if (admitEachClaimed(element, context, producer, onRejected)) {
+ admitted++;
+ }
+ }
+ } catch (Throwable t) {
+ releasePlaces(unspent);
+ throw t;
+ }
+ if (unspent > 0) {
+ releasePlaces(unspent);
+ break;
+ }
+ }
+ return admitted;
+ }
+
+ @Override
+ public final Reservation tryReserve() {
+ return new PlaceReservation<>(this, claimPlace());
+ }
+
+ @Override
+ public final boolean process(Consumer super T> consumer) {
+ return processOrRetry(consumer, null);
+ }
+
+ @Override
+ public final boolean processOrHandle(
+ Consumer super T> consumer, ExceptionHandler super T> exceptionHandler) {
+ Object raw = take();
+ if (raw == null) {
+ return false;
+ }
+ consume(raw, consumer, null, null, null, exceptionHandler);
+ return true;
+ }
+
+ @Override
+ public final boolean processOrRetry(
+ Consumer super T> consumer, RetryStrategy retryStrategy) {
+ Object raw = take();
+ if (raw == null) {
+ return false;
+ }
+ consume(raw, consumer, null, null, retryStrategy, null);
+ return true;
+ }
+
+ @Override
+ public final boolean process(C context, BiConsumer super C, ? super T> consumer) {
+ return processOrRetry(context, consumer, null);
+ }
+
+ @Override
+ public final boolean processOrRetry(
+ C context, BiConsumer super C, ? super T> consumer, RetryStrategy retryStrategy) {
+ Object raw = take();
+ if (raw == null) {
+ return false;
+ }
+ consume(raw, null, context, consumer, retryStrategy, null);
+ return true;
+ }
+
+ @Override
+ public final boolean processOrHandle(
+ C context,
+ BiConsumer super C, ? super T> consumer,
+ ExceptionHandler super T> exceptionHandler) {
+ Object raw = take();
+ if (raw == null) {
+ return false;
+ }
+ consume(raw, null, context, consumer, null, exceptionHandler);
+ return true;
+ }
+
+ @Override
+ public final int process(int limit, Consumer super T> consumer) {
+ return process(limit, consumer, null, null);
+ }
+
+ @Override
+ public final int process(int limit, C context, BiConsumer super C, ? super T> consumer) {
+ return process(limit, null, context, consumer);
+ }
+
+ private int process(
+ int limit,
+ Consumer super T> consumer,
+ C context,
+ BiConsumer super C, ? super T> biConsumer) {
+ int consumed = 0;
+ while (consumed < limit) {
+ Object raw = take();
+ if (raw == null) {
+ break;
+ }
+ // Counted before the consumer runs: a throw carries the count away with it either way, and
+ // an item handed over is consumed whether or not the consumer made anything of it.
+ // (This count is the return of process(limit, ..), not a drop count.)
+ consumed++;
+ consume(raw, consumer, context, biConsumer, null, null);
+ }
+ return consumed;
+ }
+
+ @SuppressWarnings("unchecked")
+ private void consume(
+ Object raw,
+ Consumer super T> consumer,
+ C context,
+ BiConsumer super C, ? super T> biConsumer,
+ RetryStrategy retryStrategy,
+ ExceptionHandler super T> exceptionHandler) {
+ T item;
+ int attempt;
+ if (raw instanceof Retry) {
+ Retry retried = (Retry) raw;
+ item = retried.item;
+ attempt = retried.attempt;
+ } else {
+ item = (T) raw;
+ attempt = 0;
+ }
+ if (retryStrategy == null && exceptionHandler == null) {
+ // No strategy means no opinion about failure: the throw travels out to the caller's own
+ // frame, where its existing error handling already lives. Swallowing it here would make a
+ // queue the arbiter of an error policy nobody handed it.
+ if (consumer != null) {
+ consumer.accept(item);
+ } else {
+ biConsumer.accept(context, item);
+ }
+ return;
+ }
+ try {
+ if (consumer != null) {
+ consumer.accept(item);
+ } else {
+ biConsumer.accept(context, item);
+ }
+ } catch (Throwable failure) {
+ if (exceptionHandler != null) {
+ exceptionHandler.handle(item, failure);
+ } else {
+ // The return says whether the strategy gave up, and nothing here reads it: with no drop
+ // counter, an item a strategy abandons is lost without the caller of processOrRetry being
+ // told -- processOrRetry reports only whether there was an item. It is the one loss in this
+ // class that has no synchronous channel back to the caller. See the PR notes.
+ retryStrategy.onFailure(item, attempt + 1, failure, lease(attempt + 1));
+ }
+ }
+ }
+
+ /**
+ * Built per failure rather than held in a field, which is the more expensive-looking of the two
+ * and is the right one.
+ *
+ * A field-held lease would allocate never, but it cannot carry {@code attempt}: one lease per
+ * queue means one mutable attempt number, and {@code WorkQueues#createMpmcQueue} exists so that
+ * several threads can drain at once, so two consumers failing together would overwrite each
+ * other's. The alternatives are putting {@code attempt} back into {@link RetryQueue#retry} -- a
+ * parameter every strategy must thread through whether it cares or not -- or paying for this
+ * object.
+ *
+ *
Usually it does not get paid. C2 deletes the lease wherever it can inline {@link
+ * RetryStrategy#onFailure} and see that the lease does not escape, and it can do that whenever
+ * the strategy's exact class is knowable without consulting a receiver profile: {@code
+ * RetryLeaseBenchmark} measures 0 B/op at any number of loaded strategy types for a {@code static
+ * final} strategy, for an inline lambda capturing or not, and for a field declared at a concrete
+ * final class. The one shape that pays is an interface-typed field -- {@code final} does not
+ * help, HotSpot does not trust non-static finals -- reaching a megamorphic call site. There it is
+ * 24 bytes, once per consumer failure, next to a throw that already cost far more, and it is
+ * exactly the shape where a shared mutable lease would have been racing.
+ */
+ private RetryQueue lease(int attempt) {
+ return new RetryQueue() {
+ @Override
+ public boolean retry(T item) {
+ // A refused retry is one step of a decision the strategy is still making, not an
+ // outcome; whether the item is finally lost is what onFailure's return reports.
+ return admit(new Retry<>(item, attempt));
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public boolean retry(T... items) {
+ boolean all = items.length > 0;
+ for (T item : items) {
+ all &= retry(item);
+ }
+ return all;
+ }
+ };
+ }
+
+ /**
+ * The one place the module says what a {@code null} element is. Neither backing can hold one, so
+ * there is no outcome to report -- only a caller with a bug. Thrown before a place is claimed, so
+ * a rejected call costs the queue nothing.
+ */
+ private static void requireElement(Object element) {
+ if (element == null) {
+ throw new NullPointerException("a queue cannot hold null");
+ }
+ }
+
+ @Override
+ public final void close() {
+ long current;
+ do {
+ current = state.get();
+ if (current < CLOSED_MARK) {
+ // Already closed. Applying the offset twice would walk the state toward a second
+ // threshold nothing checks, and the second close has nothing left to say.
+ return;
+ }
+ } while (!state.compareAndSet(current, current - CLOSED_OFFSET));
+ }
+
+ @Override
+ public final boolean isClosed() {
+ return state.get() < CLOSED_MARK;
+ }
+
+ @Override
+ public final void clear() {
+ discardAll();
+ }
+
+ @Override
+ public final void shutdown() {
+ close();
+ discardAll();
+ }
+}
diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/BiContextualProducer.java b/utils/queue-utils/src/main/java/datadog/common/queue/BiContextualProducer.java
new file mode 100644
index 00000000000..cfc8fc72cbb
--- /dev/null
+++ b/utils/queue-utils/src/main/java/datadog/common/queue/BiContextualProducer.java
@@ -0,0 +1,22 @@
+package datadog.common.queue;
+
+import datadog.trace.api.function.Strategy;
+
+/**
+ * A {@link Producer} that derives its element from two caller-supplied contexts.
+ *
+ * Two rather than one because the second context is typically a value the call site hoisted out
+ * of a loop — a schema, a clock reading, a per-batch buffer — that the item alone cannot recover.
+ * Carrying it as a parameter is what keeps the producer a non-capturing bound-once field and keeps
+ * the hoist visible where it happens, instead of a per-iteration capture or a cached binding that
+ * can silently go stale.
+ *
+ *
The ladder stops here on purpose. A third context is usually derivable from the item, and a
+ * primitive one has to be boxed to ride a generic parameter, which costs more than re-deriving it.
+ * A call site that genuinely needs more should close over what it needs once per scope.
+ */
+@Strategy
+@FunctionalInterface
+public interface BiContextualProducer {
+ T produce(C1 first, C2 second);
+}
diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/ContextualProducer.java b/utils/queue-utils/src/main/java/datadog/common/queue/ContextualProducer.java
new file mode 100644
index 00000000000..9e556b84697
--- /dev/null
+++ b/utils/queue-utils/src/main/java/datadog/common/queue/ContextualProducer.java
@@ -0,0 +1,15 @@
+package datadog.common.queue;
+
+import datadog.trace.api.function.Strategy;
+
+/**
+ * A {@link Producer} that derives its element from a caller-supplied context.
+ *
+ * The context parameter is what lets the producer stay non-capturing: state the element needs is
+ * passed in at the call site rather than closed over.
+ */
+@Strategy
+@FunctionalInterface
+public interface ContextualProducer {
+ T produce(C context);
+}
diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/ExceptionHandler.java b/utils/queue-utils/src/main/java/datadog/common/queue/ExceptionHandler.java
new file mode 100644
index 00000000000..7d2d996bd0a
--- /dev/null
+++ b/utils/queue-utils/src/main/java/datadog/common/queue/ExceptionHandler.java
@@ -0,0 +1,24 @@
+package datadog.common.queue;
+
+import datadog.trace.api.function.Strategy;
+
+/**
+ * Deals with a consumer's failure and lets the item go, for a caller who wants to see what went
+ * wrong without deciding whether to try again. The item is dropped either way, and the failure does
+ * not reach the caller of {@code processOrHandle}.
+ *
+ * The narrow half of {@link RetryStrategy}: reach for that one when the answer to a failure is
+ * sometimes "again", and this one when it is only ever "record it and move on". The item comes
+ * along because the consumer that threw is in no position to say which one died.
+ *
+ * @see WorkQueue#processOrHandle(java.util.function.Consumer, ExceptionHandler)
+ */
+@Strategy
+@FunctionalInterface
+public interface ExceptionHandler {
+ /**
+ * Called on the consuming thread, in place of propagating. A handler that throws propagates in
+ * the failure's stead.
+ */
+ void handle(T item, Throwable failure);
+}
diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/MaxRetries.java b/utils/queue-utils/src/main/java/datadog/common/queue/MaxRetries.java
new file mode 100644
index 00000000000..2e13dbefe16
--- /dev/null
+++ b/utils/queue-utils/src/main/java/datadog/common/queue/MaxRetries.java
@@ -0,0 +1,23 @@
+package datadog.common.queue;
+
+/**
+ * A {@link RetryStrategy} that resubmits a failed item up to a fixed number of times, then gives
+ * up.
+ *
+ * The count is retries, not consumptions: {@code new MaxRetries<>(3)} lets an item be consumed
+ * four times in all, and {@code new MaxRetries<>(0)} never resubmits. {@link
+ * RetryStrategy#onFailure} reports the first failure as attempt {@code 1}, so the comparison is
+ * against the failures so far rather than against the attempt number.
+ */
+public final class MaxRetries implements RetryStrategy {
+ private final int maxRetries;
+
+ public MaxRetries(int maxRetries) {
+ this.maxRetries = maxRetries;
+ }
+
+ @Override
+ public boolean onFailure(T item, int attempt, Throwable failure, RetryQueue retryQueue) {
+ return attempt <= maxRetries && retryQueue.retry(item);
+ }
+}
diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/MpmcWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/MpmcWorkQueue.java
new file mode 100644
index 00000000000..a5f72792a3a
--- /dev/null
+++ b/utils/queue-utils/src/main/java/datadog/common/queue/MpmcWorkQueue.java
@@ -0,0 +1,110 @@
+package datadog.common.queue;
+
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import org.jctools.queues.MessagePassingQueue;
+
+/**
+ * A {@link WorkQueue} for several consumers: an MPMC array queue when bounded, a {@link
+ * ConcurrentLinkedQueue} when not.
+ *
+ * Storage only, as with {@link MpscWorkQueue} — the bound and the reserve-before-construct
+ * guarantee live in {@link BaseWorkQueue}. One class covers both structures on purpose. They are
+ * different enough to want different types, but {@code store} and {@code retrieve} are the sites
+ * every admission and every drain funnels through, and a third implementation of those makes them
+ * megamorphic for callers that only ever touch one backing — see {@link BaseWorkQueue#store}. A
+ * predictable branch on a final field inside this class is much the cheaper way to hold two
+ * structures, and only multi-consumer callers reach it at all.
+ *
+ *
Bounded is array-backed because that is the better structure: measured against the linked
+ * queue at 12.7ns against 20.6ns per admit-and-drain, and no per-element node, so it stops
+ * manufacturing 24 bytes of garbage per element. Unbounded stays linked because JCTools' unbounded
+ * MPMC queue exists only in its {@code Unsafe} form, and {@link Queues} deliberately moves off
+ * {@code Unsafe} on Java 25 and later.
+ *
+ *
The bounded backing is a JCTools MPMC ring; consult the JCTools documentation for its
+ * semantics. What matters here is that its {@code offer} can refuse a queue that is not full, and
+ * that the bound does not live in the ring: a place is claimed before {@link #store} is ever
+ * called, so a refusal can only mean not yet, and retrying will succeed. The retry is bounded
+ * anyway — if the accounting were ever wrong, an unbounded spin would turn a bug into a hang. See
+ * {@link Queues#mpmcArrayQueue} for why an ordinary caller cannot use the ring this way.
+ */
+final class MpmcWorkQueue extends BaseWorkQueue {
+
+ /**
+ * The smallest ring JCTools will build. A caller asking for one place gets two rather than an
+ * {@link IllegalArgumentException}, in the spirit of rounding the capacity up.
+ */
+ private static final int MINIMUM_CAPACITY = 2;
+
+ /**
+ * How many times a claimed place will re-offer before giving up on it.
+ *
+ * The window it is riding out is one thread's publish, so almost every retry that happens at
+ * all succeeds on its next attempt. The yields are for the case the spin cannot fix — a producer
+ * descheduled between claiming its slot and filling it — where spinning without giving the core
+ * up would just burn the window down.
+ */
+ private static final int STORE_ATTEMPTS = 64;
+
+ private static final int YIELD_EVERY = 16;
+
+ private final Queue queue;
+
+ /** Bounded, and array-backed: the reason this class exists. */
+ @SuppressWarnings("unchecked")
+ static MpmcWorkQueue bounded(int requestedCapacity) {
+ MessagePassingQueue ring =
+ Queues.mpmcArrayQueue(Math.max(MINIMUM_CAPACITY, requestedCapacity));
+ // Every JCTools array queue is an AbstractQueue; the cast is checked once, here, and never on
+ // the admission path. The bound is the capacity the ring actually rounded up to, not the one
+ // that was asked for, so the counter and the ring can never disagree about what full means.
+ return new MpmcWorkQueue<>((Queue) ring, ring.capacity());
+ }
+
+ /**
+ * Unbounded, for call sites that have no defensible capacity yet. Admission never rejects, so the
+ * queue buys the lifecycle and the interface and nothing else; it is a step on the way to picking
+ * a bound, not a destination.
+ */
+ static MpmcWorkQueue unbounded() {
+ return new MpmcWorkQueue<>(new ConcurrentLinkedQueue<>(), Integer.MAX_VALUE);
+ }
+
+ private MpmcWorkQueue(Queue queue, int capacity) {
+ super(capacity);
+ this.queue = queue;
+ }
+
+ @Override
+ boolean store(Object element) {
+ // Small enough to inline into the admission path, which is the only reason the retry is a
+ // separate method: a loop here made this too big for C2 to inline and cost every admission
+ // more than the array ring was saving them.
+ return queue.offer(element) || storeRetrying(element);
+ }
+
+ /**
+ * The refusal is transient by construction — a place was claimed, so a slot exists. Reached
+ * roughly once in four hundred offers with four producers and four consumers on a ring of eight,
+ * and not at all on a linked queue, which never refuses.
+ */
+ private boolean storeRetrying(Object element) {
+ for (int attempt = 1; attempt < STORE_ATTEMPTS; attempt++) {
+ if (attempt % YIELD_EVERY == 0) {
+ // The spin cannot help against a producer descheduled between claiming its slot and
+ // filling it; giving the core up can.
+ Thread.yield();
+ }
+ if (queue.offer(element)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ Object retrieve() {
+ return queue.poll();
+ }
+}
diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java
new file mode 100644
index 00000000000..95b8d087f27
--- /dev/null
+++ b/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java
@@ -0,0 +1,43 @@
+package datadog.common.queue;
+
+import org.jctools.queues.MessagePassingQueue;
+
+/**
+ * A {@link WorkQueue} over a JCTools MPSC array queue: many producers, one consumer, no per-element
+ * node. The preferred backing.
+ *
+ * Storage only. The bound and the reserve-before-construct guarantee both live in {@link
+ * BaseWorkQueue}, which spends a place before it calls any producer, so by the time an element
+ * reaches {@link #store} the ring is known to have room for it.
+ *
+ *
That the ring could have enforced its own bound, inside a CAS it was performing anyway, is the
+ * cost of this arrangement — see {@link BaseWorkQueue} for what it buys. What it avoids is holding
+ * a ring position open across a caller-controlled gap: the ring reports a claimed-but-unfilled
+ * position as empty, so a reservation that held one would stall the consumer, and would need a
+ * placeholder object per reservation for the consumer to tell an abandoned position from a pending
+ * one.
+ */
+final class MpscWorkQueue extends BaseWorkQueue {
+
+ private final MessagePassingQueue queue;
+
+ MpscWorkQueue(int requestedCapacity) {
+ this(Queues.mpscArrayQueue(requestedCapacity));
+ }
+
+ /** Takes the queue already built, so the bound can be the capacity it actually rounded up to. */
+ private MpscWorkQueue(MessagePassingQueue queue) {
+ super(queue.capacity());
+ this.queue = queue;
+ }
+
+ @Override
+ boolean store(Object element) {
+ return queue.offer(element);
+ }
+
+ @Override
+ Object retrieve() {
+ return queue.poll();
+ }
+}
diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/Producer.java b/utils/queue-utils/src/main/java/datadog/common/queue/Producer.java
new file mode 100644
index 00000000000..8b384c7daea
--- /dev/null
+++ b/utils/queue-utils/src/main/java/datadog/common/queue/Producer.java
@@ -0,0 +1,22 @@
+package datadog.common.queue;
+
+import datadog.trace.api.function.Strategy;
+
+/**
+ * Produces an element for admission into a {@link WorkQueue}.
+ *
+ * A producer is only invoked once a place has been claimed, so it is never called for an element
+ * that will be rejected. Implementations must be non-capturing — a {@code static final} constant of
+ * the concrete type, or a lambda that closes over nothing — which is what {@link Strategy} marks.
+ *
+ *
That is not a preference, it is the whole reason this form exists. A capturing lambda
+ * allocates once per call, and so does a {@link Reservation}; the reservation is straight-line code
+ * that keeps whatever the call site had hoisted and needs no context parameters. So a producer that
+ * captures is strictly worse than the reserve form it was meant to improve on. If the state will
+ * not fit the context parameters, use {@link WorkQueue#tryReserve} rather than closing over it.
+ */
+@Strategy
+@FunctionalInterface
+public interface Producer {
+ T produce();
+}
diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/Queues.java b/utils/queue-utils/src/main/java/datadog/common/queue/Queues.java
index 9c3de5fac8a..478b1d4d2bc 100644
--- a/utils/queue-utils/src/main/java/datadog/common/queue/Queues.java
+++ b/utils/queue-utils/src/main/java/datadog/common/queue/Queues.java
@@ -2,9 +2,11 @@
import datadog.environment.JavaVirtualMachine;
import org.jctools.queues.MessagePassingQueue;
+import org.jctools.queues.MpmcArrayQueue;
import org.jctools.queues.MpscArrayQueue;
import org.jctools.queues.SpmcArrayQueue;
import org.jctools.queues.SpscArrayQueue;
+import org.jctools.queues.varhandle.MpmcVarHandleArrayQueue;
import org.jctools.queues.varhandle.MpscVarHandleArrayQueue;
import org.jctools.queues.varhandle.SpmcVarHandleArrayQueue;
import org.jctools.queues.varhandle.SpscVarHandleArrayQueue;
@@ -43,6 +45,28 @@ public static MessagePassingQueue mpscArrayQueue(int requestedCapacity) {
return new MpscArrayQueue<>(requestedCapacity);
}
+ /**
+ * Creates a Multiple Producer, Multiple Consumer (MPMC) array-backed queue.
+ *
+ * {@code offer} is non-linearizable, and deliberately so: it can refuse while another thread
+ * is midway through publishing to the slot it is looking at, on a queue that is neither full nor
+ * empty. Measured at roughly 0.24% of offers with four producers and four consumers on a queue of
+ * eight. A caller that treats a refusal as "full" will therefore drop work it had room for;
+ * either retry, or hold the bound somewhere the queue cannot lie about -- which is what {@link
+ * WorkQueues#createMpmcQueue} does. {@code poll} carries no matching hazard: it spins for a
+ * pending publish rather than reporting empty, so it can be slow where {@code offer} is wrong.
+ *
+ * @param requestedCapacity the requested capacity of the queue. Will be rounded to the next power
+ * of two, and is not permitted to be less than two.
+ * @return a new {@link MessagePassingQueue} instance suitable for MPMC usage
+ */
+ public static MessagePassingQueue mpmcArrayQueue(int requestedCapacity) {
+ if (CAN_USE_VARHANDLES) {
+ return new MpmcVarHandleArrayQueue<>(requestedCapacity);
+ }
+ return new MpmcArrayQueue<>(requestedCapacity);
+ }
+
/**
* Creates a Single Producer, Multiple Consumer (SPMC) array-backed queue.
*
diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/RejectHandler.java b/utils/queue-utils/src/main/java/datadog/common/queue/RejectHandler.java
new file mode 100644
index 00000000000..afab1042290
--- /dev/null
+++ b/utils/queue-utils/src/main/java/datadog/common/queue/RejectHandler.java
@@ -0,0 +1,27 @@
+package datadog.common.queue;
+
+import datadog.trace.api.function.Strategy;
+
+/**
+ * Sees the source elements a batch admission could not take, for a caller that has somewhere to put
+ * them: a resubmission list, a spill buffer, a per-kind counter.
+ *
+ * A handler rather than a returned collection, so a caller that only wanted the count is charged
+ * nothing for one it would have thrown away, and a caller that wants the elements chooses where
+ * they go instead of receiving a list it has to copy out of. The admission side's answer to {@link
+ * ExceptionHandler}, which does the same thing for a consumer's failures.
+ *
+ *
Only refusals reach a handler. An element the producer declined by returning {@code null} was
+ * the caller's own decision and is not a rejection. The queue cannot hold that line perfectly at
+ * the boundary, though: a place is claimed before the producer is asked, so once the queue is full
+ * a handler sees source elements the producer would have declined, indistinguishable from the rest.
+ * A caller resubmitting what it is handed should apply its own decline rule again.
+ *
+ * @see WorkQueue#tryPutBatch(java.util.Collection, Object, BiContextualProducer, RejectHandler)
+ */
+@Strategy
+@FunctionalInterface
+public interface RejectHandler {
+ /** Called on the admitting thread, once per source element that could not be admitted. */
+ void onRejected(E element);
+}
diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/Reservation.java b/utils/queue-utils/src/main/java/datadog/common/queue/Reservation.java
new file mode 100644
index 00000000000..e901ab86bf2
--- /dev/null
+++ b/utils/queue-utils/src/main/java/datadog/common/queue/Reservation.java
@@ -0,0 +1,57 @@
+package datadog.common.queue;
+
+/**
+ * A claimed place in a {@link WorkQueue}, for a caller whose work between claiming and filling
+ * cannot be expressed as a {@link Producer}.
+ *
+ * What is claimed is capacity, never a position: the element joins the queue where it is filled,
+ * not where it was claimed, so an open reservation holds no place a consumer could be waiting on
+ * and cannot stall one. A reservation that is neither filled nor closed does leak its capacity,
+ * quietly and permanently, which is why this is an {@link AutoCloseable} meant for
+ * try-with-resources.
+ *
+ *
A refused claim is a reservation too, rather than a {@code null}, and one that quietly
+ * discards whatever is filled into it. Nothing about the failed path throws, so the shortest
+ * correct call site is also the obvious one:
+ *
+ *
{@code
+ * try (Reservation place = queue.tryReserve()) {
+ * place.fill(buildTask());
+ * }
+ * }
+ *
+ * Consulting {@link #granted} first is what buys the reserve-first guarantee — skip the build
+ * and nothing is allocated for a queue that had no room for it:
+ *
+ *
{@code
+ * try (Reservation place = queue.tryReserve()) {
+ * if (place.granted()) {
+ * place.fill(buildTask());
+ * }
+ * }
+ * }
+ */
+public interface Reservation extends AutoCloseable {
+
+ /**
+ * Whether a place was actually claimed. Worth asking before building anything expensive: a
+ * refused reservation accepts a fill and throws it away, so checking is what turns
+ * allocate-then-drop into never-allocate.
+ *
+ * @return whether a fill will be kept
+ */
+ boolean granted();
+
+ /**
+ * Publishes {@code element} into the claimed place. A granted place is already paid for, so this
+ * cannot be rejected; a refused one discards the element, having already counted the drop.
+ */
+ void fill(T element);
+
+ /**
+ * Gives the place back if it was never filled, immediately. Filling first makes this a no-op, and
+ * nothing is ever consumed for a released place.
+ */
+ @Override
+ void close();
+}
diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java
new file mode 100644
index 00000000000..b10fba899dd
--- /dev/null
+++ b/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java
@@ -0,0 +1,41 @@
+package datadog.common.queue;
+
+import javax.annotation.Nonnull;
+
+/**
+ * The capability to resubmit work after a consumer failure.
+ *
+ * Only obtainable inside {@link RetryStrategy#onFailure}, never from a plain consumer, so
+ * re-enqueue-after-failure stays visibly distinct from ordinary admission.
+ */
+public interface RetryQueue {
+ /**
+ * Resubmits the failed item.
+ *
+ * The failed item's place was given back when it was consumed, so this claims a place like any
+ * other admission and can be rejected if the queue filled up behind it. A refusal here is one
+ * step of the strategy's decision, not its outcome; {@link RetryStrategy#onFailure} returning
+ * {@code false} is what says the item was finally given up on. This is the overload every
+ * ordinary strategy wants: it resubmits without allocating the array the varargs form needs.
+ *
+ * @param item the failed item, which must not be {@code null}: a resubmission travels wrapped in
+ * its attempt count, and the wrapper is what the admission path null-checks, so a null here
+ * is not turned away -- it reaches the next consumer instead
+ * @return whether the item was resubmitted
+ */
+ boolean retry(@Nonnull T item);
+
+ /**
+ * Resubmits several items in place of the failed item.
+ *
+ *
Each piece claims its own place, so a partition can be admitted only in part, and the return
+ * value reports whether all of them made it. As with the single-item overload, a refusal is not
+ * counted here; a strategy that partially resubmits and returns {@code true} is telling the queue
+ * the remainder was its own to lose.
+ *
+ * @param items the replacement items, none of which may be {@code null}
+ * @return whether every item was resubmitted
+ */
+ @SuppressWarnings("unchecked")
+ boolean retry(@Nonnull T... items);
+}
diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/RetryStrategy.java b/utils/queue-utils/src/main/java/datadog/common/queue/RetryStrategy.java
new file mode 100644
index 00000000000..e8da41df964
--- /dev/null
+++ b/utils/queue-utils/src/main/java/datadog/common/queue/RetryStrategy.java
@@ -0,0 +1,21 @@
+package datadog.common.queue;
+
+import datadog.trace.api.function.Strategy;
+
+/**
+ * Decides what happens to an item whose consumer threw.
+ *
+ *
Invoked only on failure — a successful consumption needs no callback. The return value reports
+ * the decision; it does not report whether the item will eventually succeed. Logging and counting
+ * are the caller's to compose here: this API performs neither.
+ */
+@Strategy
+@FunctionalInterface
+public interface RetryStrategy {
+ /**
+ * @param attempt how many times this item has been consumed unsuccessfully, including now, so the
+ * first failure reports {@code 1}
+ * @return {@code true} if the item was resubmitted, {@code false} if the strategy gave up
+ */
+ boolean onFailure(T item, int attempt, Throwable failure, RetryQueue retryQueue);
+}
diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java
new file mode 100644
index 00000000000..c3c604bcb3b
--- /dev/null
+++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java
@@ -0,0 +1,379 @@
+package datadog.common.queue;
+
+import datadog.trace.api.function.Strategy;
+import datadog.trace.api.function.StrategyConsumer;
+import java.util.Collection;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+/**
+ * A bounded handoff point between producers and a consumer, with admission that never builds an
+ * element it is going to reject.
+ *
+ * Why to use this instead of a queue and a call site
+ *
+ * Every failure below was found in this tree, in code that offers into a {@code
+ * java.util.concurrent} queue by hand. They are not hypothetical, and none of them is anyone's
+ * carelessness -- each is what the shape of {@code offer} invites. The last column is the honest
+ * one: this API makes some of them impossible, some merely unlikely, one it only makes explicit,
+ * and one it does not address at all.
+ *
+ *
+ * Hand-rolled admission failures, and what this API does about them
+ * Failure Seen as Here
+ *
+ * The refusal is discarded, so a drop is invisible
+ * Seven call sites in {@code RumInjectorMetrics}; two in {@code WafMetricCollector}, which
+ * tests the return at a dozen sites and drops it at two; {@code ProductChangeCollector};
+ * {@code IntegrationsCollector}
+ * Not addressed. Refusal is reported only through the return value, so a caller
+ * that ignores it loses the element as silently as before. A drop counter owned by the
+ * queue would cover this and is deliberately not in this version -- every other refusal
+ * here is already reported synchronously, so it would have been a safety net for a
+ * negligent caller rather than new information, and nothing yet surfaces it. What does
+ * help is that {@link #tryPutBatch} answers for a whole drain at once, leaving one return
+ * to ignore where a hand-rolled loop leaves one per element
+ *
+ *
+ * State is destroyed to build an element that is then refused, losing it permanently
+ * {@code WafMetricCollector} does {@code getAndSet(i, 0)} on a counter, then abandons the
+ * metric if the queue is full -- the delta is gone. {@code CoreMetricCollector} carries a
+ * comment about avoiding exactly this, so it is known
+ * Impossible if admission is reached through a producer or a {@link Reservation}: the
+ * place is claimed before the source is consumed, so there is no window in which a caller
+ * has destroyed something it cannot hand off
+ *
+ *
+ * The first refusal abandons the rest of the batch, silently
+ * {@code WafMetricCollector} returns out of its publish loop on a failed offer
+ * Impossible. {@link #tryPutBatch} reports how many it admitted, or hands back the
+ * elements it refused, so a partial admission is a number rather than a guess
+ *
+ *
+ * A capacity check that another thread invalidates before the offer
+ * {@code CoreMetricCollector}'s {@code remainingCapacity() == 0};
+ * {@code FlagEvaluationWriterImpl}'s {@code size() < capacity()}, whose javadoc correctly
+ * calls itself best-effort
+ * Impossible. The check is the claim -- a granted place cannot be taken by a
+ * racing producer, so there is no check-then-act to lose
+ *
+ *
+ * The element is built before anyone asks whether there is room
+ * Most sites, since the element is the argument to {@code offer}
+ * Unlikely. The producer forms make the cheap order the natural one, but
+ * {@link #tryPut(Object)} still lets a caller build first, on purpose, for elements that
+ * already exist
+ *
+ *
+ * An unbounded queue, so pressure becomes heap
+ * {@code ProductChangeCollector} and {@code IntegrationsCollector} are
+ * {@code new LinkedBlockingQueue<>()}
+ * Only made explicit. A bound is required unless a caller names
+ * {@code WorkQueues.createUnboundedMpmcQueue}, which is a decision rather than a default
+ *
+ *
+ *
+ * Worth reading {@code FlagEvaluationWriterImpl} as the counter-example: it is bounded, it
+ * counts its drops, it surfaces the count as a metric, and it documents its own race honestly. It
+ * is proof the discipline is achievable by hand -- at the cost of that team working it out and
+ * writing it down themselves, which is the cost this module exists to pay once. Note that its drop
+ * counter is the one part of it this API does not replace: counting and publishing a refusal stays
+ * the caller's, which is the same division {@code FlagEvaluationWriterImpl} already chose.
+ *
+ *
Capacity is fixed by construction. A queue never grows in response to fullness: full means
+ * refuse. Admission claims a place before invoking any producer, so a rejected element is never
+ * constructed at all — the guarantee that makes it safe to hand this a producer that allocates
+ * heavily, since the allocation cannot happen on the path where it would be wasted.
+ *
+ *
What is claimed is capacity, never a position, so a producer never holds up a consumer. It
+ * does hold a place other producers could have used, though: work that blocks, or that takes
+ * appreciably longer than an allocation, is paid for by everyone else admitting to this queue.
+ * Producers should build their element and nothing else.
+ *
+ *
Prefer the producer forms, and treat {@link #tryReserve} as the fallback. The distinction is
+ * {@code forEach} against {@code Iterator}: with a producer the queue owns the sequence, claiming
+ * and building in the order it knows to be safe, and there is no protocol for a caller to get
+ * wrong. A reservation hands that loop back — the caller must check {@link Reservation#granted},
+ * must fill or close, and an abandoned one is capacity nobody can see or reclaim, the same way a
+ * half-consumed iterator is state its collection cannot account for. Reach for it when the work
+ * between claiming and filling genuinely will not fold into a callback, and use {@code tryPut}
+ * everywhere else.
+ *
+ *
Consumption is synchronous and happens in the caller's frame; the boolean returned by the
+ * {@code process} methods reports whether there was an item to work on, which is the signal a drain
+ * loop needs, and says nothing about whether the consumer succeeded. A consumer that throws throws
+ * out of {@code process} — the queue takes no view on failure it was not given one for, and never
+ * logs. Say what should happen instead by calling {@link #processOrRetry} with a {@link
+ * RetryStrategy}, or {@link #processOrHandle} with an {@link ExceptionHandler} when the answer is
+ * only ever to record it and move on. Those are separate names rather than overloads because a
+ * lambda or method reference cannot always tell two same-arity callbacks apart.
+ *
+ *
Nulls carry meaning in three places and are a bug everywhere else. A context may be
+ * null: the queue carries it to a producer or consumer and never looks at it, so an absent one is
+ * the caller's business. An optional {@link RejectHandler} may be null, which says exactly what the
+ * overload without it says. And a producer's return may be null, which is that producer
+ * declining the element it was asked to build — the place goes back and nothing is admitted, which
+ * reads as a refusal to the caller even though nothing was lost.
+ *
+ *
Everything else is required. An element is never null, because neither backing can hold
+ * one: there is no outcome to report, so {@code tryPut} and {@link Reservation#fill fill} throw
+ * instead of returning, and they throw before claiming a place so that a call with a bug in it
+ * costs the queue nothing. A null inside a batch throws partway through, abandoning the rest.
+ * Producers, consumers, retry strategies and exception handlers are required too — a null
+ * there has no sensible reading, and it surfaces as the thrown {@link NullPointerException} of the
+ * call that would have used it, with any place already claimed given back first.
+ */
+public interface WorkQueue {
+
+ /**
+ * @return whether the element was admitted
+ * @throws NullPointerException if the element is null, thrown before a place is claimed
+ */
+ boolean tryPut(T element);
+
+ /**
+ * Admits an element, constructing it only once a slot is reserved.
+ *
+ * A producer returning {@code null} declines: its place goes back, nothing is admitted, and
+ * nothing was lost. The {@code false} that comes back is the same {@code false} a full queue
+ * gives, so a caller whose producer declines conditionally -- a drain that skips a counter
+ * sitting at zero, say -- cannot tell "nothing to send" from "no room" from this return alone.
+ * {@link #tryPutBatch(Collection, Object, BiContextualProducer)} separates the two, by reporting
+ * how many of the source elements it admitted.
+ *
+ * @return whether the element was admitted, which is {@code false} both for a refusal and for a
+ * producer that declined
+ */
+ @StrategyConsumer
+ boolean tryPut(@Strategy Producer extends T> producer);
+
+ /**
+ * Admits an element derived from {@code context}, constructing it only once a slot is reserved.
+ *
+ * @return whether the element was admitted; a producer returning {@code null} declines, and reads
+ * as a refusal here -- see {@link #tryPut(Producer)}
+ */
+ @StrategyConsumer
+ boolean tryPut(C context, @Strategy ContextualProducer super C, ? extends T> producer);
+
+ /**
+ * Admits an element derived from two contexts, constructing it only once a slot is reserved.
+ *
+ * @return whether the element was admitted; a producer returning {@code null} declines, and reads
+ * as a refusal here -- see {@link #tryPut(Producer)}
+ * @see BiContextualProducer
+ */
+ @StrategyConsumer
+ boolean tryPut(
+ C1 first,
+ C2 second,
+ @Strategy BiContextualProducer super C1, ? super C2, ? extends T> producer);
+
+ /**
+ * @return the elements that were not admitted, empty if all were
+ */
+ @SuppressWarnings("unchecked")
+ Collection tryPutBatch(T... elements);
+
+ /**
+ * @return the elements that were not admitted, empty if all were
+ */
+ Collection tryPutBatch(Collection extends T> elements);
+
+ /**
+ * Admits an element per source element, constructing each only once a slot is reserved for it.
+ * The queue owns the walk, so the producer is asked only for elements there is already room for,
+ * and a caller batching work this way never holds capacity of its own.
+ *
+ * The producer may decline a source element by returning {@code null}. That is an explicit
+ * decision by the caller rather than a loss, so a declined element does not count as admitted;
+ * the place claimed for it is simply given back.
+ *
+ *
A count rather than the refused source elements, because the count is the number a caller
+ * can act on and the elements are not. A caller that knows how many it meant to admit gets the
+ * exact shortfall by subtraction, with its own declines excluded from both sides. The refused
+ * elements cannot be that precise: a place is claimed before the producer is asked, so a full
+ * queue cannot tell a genuine refusal from an element the producer would have declined anyway,
+ * and hands back some of each.
+ *
+ *
{@code context} is the one value the whole batch shares and a source element cannot recover
+ * on its own — a schema, a clock reading, a per-batch buffer. It is read once here rather than
+ * per element, which is the hoist the single-element form spells out in {@link
+ * BiContextualProducer}.
+ *
+ *
{@link Collection} rather than {@link Iterable} because admission runs while there is room,
+ * and a queue with a live consumer keeps making room: a source with no end would not terminate.
+ *
+ *
Reach for this only when the walk exists to admit and nothing else. The queue stops asking
+ * once it runs out of room, so the producer is the only per-source-element hook a caller gets and
+ * it is reached only for elements there was room for. A loop that also carries something across
+ * its iterations — a count of what it considered, a flag OR-ed over the whole source, a decision
+ * about the batch as a whole — needs every source element regardless of admission, and hands back
+ * more per element than a producer can return. Such a caller keeps its own loop and admits one
+ * element at a time; that is not a shortcoming of the loop.
+ *
+ * @return how many elements were admitted
+ * @see BiContextualProducer
+ */
+ @StrategyConsumer
+ int tryPutBatch(
+ Collection extends E> source,
+ C context,
+ @Strategy BiContextualProducer super E, ? super C, ? extends T> producer);
+
+ /**
+ * As {@link #tryPutBatch(Collection, Object, BiContextualProducer)}, handing each source element
+ * it could not admit to {@code onRejected} on the way past.
+ *
+ * Elements the producer declined do not reach the handler; refusals do. See {@link
+ * RejectHandler} for the one place that line blurs.
+ *
+ * @return how many elements were admitted
+ * @see RejectHandler
+ */
+ @StrategyConsumer
+ int tryPutBatch(
+ Collection extends E> source,
+ C context,
+ @Strategy BiContextualProducer super E, ? super C, ? extends T> producer,
+ @Strategy RejectHandler super E> onRejected);
+
+ /**
+ * Claims a place without supplying its element, for a caller whose work between claiming and
+ * filling cannot be expressed as a {@link Producer}.
+ *
+ * What is reserved is capacity, not a position — {@link Reservation#fill} cannot be rejected,
+ * and the element joins the queue where it is filled. Nothing is held open that a consumer could
+ * be waiting on, so a thread may safely reserve and consume, but a reservation that is never
+ * filled or closed leaks its capacity for good. Use try-with-resources.
+ *
+ *
Never {@code null}: a refusal comes back as a reservation that reports {@link
+ * Reservation#granted} as {@code false} and discards anything filled into it. Nothing on the
+ * refused path throws, so the try-with-resources is always safe; checking {@code granted} is what
+ * lets the caller skip building an element the queue had no room for.
+ *
+ * @return the claimed capacity, or a refused reservation if there was none to claim
+ */
+ Reservation tryReserve();
+
+ /**
+ * Consumes one item, if there is one. A throwing consumer propagates.
+ *
+ * @return whether there was an item to consume
+ */
+ boolean process(Consumer super T> consumer);
+
+ /**
+ * Consumes one item, if there is one, handing a throwing consumer's failure to {@code
+ * retryStrategy} rather than propagating it.
+ *
+ * @return whether there was an item to consume
+ */
+ boolean processOrRetry(Consumer super T> consumer, @Strategy RetryStrategy retryStrategy);
+
+ /**
+ * Consumes one item, if there is one, handing a throwing consumer's failure to {@code
+ * exceptionHandler} rather than propagating it. The item is dropped.
+ *
+ * @return whether there was an item to consume
+ */
+ boolean processOrHandle(
+ Consumer super T> consumer, @Strategy ExceptionHandler super T> exceptionHandler);
+
+ /**
+ * Consumes one item, if there is one. A throwing consumer propagates.
+ *
+ * @return whether there was an item to consume
+ */
+ boolean process(C context, BiConsumer super C, ? super T> consumer);
+
+ /**
+ * Consumes one item, if there is one, handing a throwing consumer's failure to {@code
+ * retryStrategy} rather than propagating it.
+ *
+ * @return whether there was an item to consume
+ */
+ boolean processOrRetry(
+ C context,
+ BiConsumer super C, ? super T> consumer,
+ @Strategy RetryStrategy retryStrategy);
+
+ /**
+ * Consumes one item, if there is one, handing a throwing consumer's failure to {@code
+ * exceptionHandler} rather than propagating it. The item is dropped.
+ *
+ * @return whether there was an item to consume
+ */
+ boolean processOrHandle(
+ C context,
+ BiConsumer super C, ? super T> consumer,
+ @Strategy ExceptionHandler super T> exceptionHandler);
+
+ /**
+ * Consumes up to {@code limit} items, stopping early when the queue runs dry.
+ *
+ * The limit is required, and there is no consume-until-empty form. Against live producers that
+ * has no reason to ever return; on an unbounded backing there is not even a capacity to fall back
+ * on as an implicit bound; and a {@link RetryStrategy} re-admits behind a consumer that is still
+ * draining, so only a caller-named ceiling guarantees the batch ends. The limit is also the
+ * caller's latency knob: a drain occupies its thread until it is done, which matters most where
+ * that thread is shared with other subsystems.
+ *
+ *
A throwing consumer propagates, abandoning the rest of the batch. Items already consumed
+ * stay consumed and the count is lost with the stack unwind, so a caller that needs it should
+ * drain in smaller batches or handle failure per item with a {@link RetryStrategy}.
+ *
+ * @return how many items were consumed, which is {@code limit} when the batch filled and there
+ * may be more waiting
+ */
+ int process(int limit, Consumer super T> consumer);
+
+ /**
+ * Consumes up to {@code limit} items, stopping early when the queue runs dry.
+ *
+ * @return how many items were consumed
+ * @see #process(int, Consumer)
+ */
+ int process(int limit, C context, BiConsumer super C, ? super T> consumer);
+
+ /**
+ * An O(1) count of the places currently spent: elements held, plus places claimed by producers
+ * that have not stored into the queue yet.
+ *
+ * A snapshot, and never outside {@code 0..capacity}: claimants racing at the boundary can
+ * transiently spend past the bound before backing out, and the report is clamped rather than
+ * allowed to show that. So a caller may see {@code capacity} on a queue that is about to have
+ * room, but never a number it cannot act on.
+ *
+ * @return how much of the bound is in use, from zero to the capacity the queue was built with
+ */
+ int size();
+
+ /**
+ * Stops future admission, leaving current contents alone so a consumer can finish its backlog.
+ *
+ *
A caller distinguishes "transiently full, worth retrying" from "permanently done" by asking
+ * {@link #isClosed()}; the {@code boolean} returned by admission does not carry the difference.
+ */
+ void close();
+
+ boolean isClosed();
+
+ /** Discards current contents without affecting admission. */
+ void clear();
+
+ /**
+ * {@link #close() Closes} and then {@link #clear() clears} — the flag before the discard, so a
+ * producer that has not started yet cannot begin.
+ *
+ *
Not atomic, and not made atomic by being one call. A producer already past the closed check,
+ * or an in-flight retry lease, can still store its element after the discard has run, and that
+ * element then sits in a queue nothing will drain again. Ordering the flag first bounds the
+ * survivors to those already in flight rather than eliminating them.
+ *
+ *
A caller that needs the queue provably empty has to quiesce its producers first and shut
+ * down after. The queue cannot do that half on the caller's behalf: it knows when it is closed,
+ * but not who is still holding a place or how long they mean to hold it.
+ */
+ void shutdown();
+}
diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java
new file mode 100644
index 00000000000..bda2d207f54
--- /dev/null
+++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java
@@ -0,0 +1,68 @@
+package datadog.common.queue;
+
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+/**
+ * Factory methods for {@link WorkQueue} buffers: bounded handoff points that count what they drop
+ * and never build an element they are going to reject.
+ *
+ *
Distinct from {@link Queues}, which hands back a raw JCTools queue for the caller to drive
+ * itself. A buffer created here owns its backing — which implementation it is stays an
+ * implementation detail, so a call site can be re-backed without changing.
+ */
+public final class WorkQueues {
+
+ private WorkQueues() {}
+
+ /**
+ * Creates a bounded Multiple Producer, Single Consumer buffer backed by an MPSC array queue.
+ *
+ *
The preferred backing: no per-element node, constant-time {@link WorkQueue#size()}, and
+ * admission that claims a slot before invoking a producer, so an element that will not fit is
+ * never built.
+ *
+ *
Single Consumer is a requirement, not a characteristic. Producers may be any number of
+ * threads, but every call that takes elements out -- any {@code process}, {@code processOrRetry}
+ * or {@code processOrHandle} overload, plus {@link WorkQueue#clear} and {@link
+ * WorkQueue#shutdown} -- must come from one thread. A second consumer is not rejected and does
+ * not throw: the two can spin inside the ring's gap-wait indefinitely, which presents as a hang
+ * rather than a failure. Use {@link #createMpmcQueue} where more than one thread drains.
+ *
+ * @param requestedCapacity the bound. Will be rounded to the next power of two.
+ */
+ public static WorkQueue createMpscQueue(int requestedCapacity) {
+ return new MpscWorkQueue<>(requestedCapacity);
+ }
+
+ /**
+ * Creates a bounded Multiple Producer, Multiple Consumer buffer backed by an MPMC array queue.
+ *
+ * For call sites that need several consumers. No per-element node, so it costs an admission
+ * and a drain about what the MPSC ring does; prefer {@link #createMpscQueue} anyway where a
+ * single consumer is possible, because the MPSC ring is cheaper still and does not have to ride
+ * out the MPMC ring's transient refusals — see {@link MpmcWorkQueue} for what those are and why
+ * claiming a place first makes them harmless.
+ *
+ * @param requestedCapacity the bound. Will be rounded to the next power of two, and raised to two
+ * if it is less than that.
+ */
+ public static WorkQueue createMpmcQueue(int requestedCapacity) {
+ return MpmcWorkQueue.bounded(requestedCapacity);
+ }
+
+ /**
+ * Creates an unbounded Multiple Producer, Multiple Consumer buffer backed by a {@link
+ * ConcurrentLinkedQueue}.
+ *
+ * Unbounded means admission never rejects, so the only element this can lose is one a retry
+ * strategy gives up on. Intended as a migration step for call sites that are unbounded today:
+ * adopt the interface here, then pick a bound and move to {@link #createMpscQueue}.
+ *
+ *
Linked rather than array-backed because there is no capacity to size an array from. JCTools'
+ * unbounded MPMC queue would avoid the per-element node, but exists only in an {@code Unsafe}
+ * form, which {@link Queues} deliberately steps away from on Java 25 and later.
+ */
+ public static WorkQueue createUnboundedMpmcQueue() {
+ return MpmcWorkQueue.unbounded();
+ }
+}
diff --git a/utils/queue-utils/src/test/java/datadog/common/queue/MpscWorkQueueStressTest.java b/utils/queue-utils/src/test/java/datadog/common/queue/MpscWorkQueueStressTest.java
new file mode 100644
index 00000000000..0171b5ec56a
--- /dev/null
+++ b/utils/queue-utils/src/test/java/datadog/common/queue/MpscWorkQueueStressTest.java
@@ -0,0 +1,266 @@
+package datadog.common.queue;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicIntegerArray;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Contention tests for the MPSC backing, where the admission contract actually has to hold: many
+ * producers claiming slots against a single consumer freeing them.
+ *
+ * What is being checked is conservation. Every element a producer was told it admitted must
+ * reach the consumer exactly once, and every admission must be told one thing or the other — so
+ * admitted plus refused, as the producers themselves saw it, accounts for everything offered, with
+ * nothing lost, duplicated or invented in between. The producers keep that tally, because the
+ * return of {@code tryPut} is the only report a refusal gets.
+ */
+class MpscWorkQueueStressTest {
+
+ private static final int PRODUCERS = 8;
+ private static final int PER_PRODUCER = 20_000;
+ private static final int TOTAL = PRODUCERS * PER_PRODUCER;
+ private static final int CAPACITY = 128;
+ private static final long TIMEOUT_SECONDS = 60;
+
+ @Test
+ void conservesEveryElementUnderContention() throws Exception {
+ WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY);
+ AtomicIntegerArray timesSeen = new AtomicIntegerArray(TOTAL);
+ AtomicInteger admitted = new AtomicInteger();
+ AtomicInteger refused = new AtomicInteger();
+ AtomicInteger consumed = new AtomicInteger();
+
+ CountDownLatch start = new CountDownLatch(1);
+ CountDownLatch producersDone = new CountDownLatch(PRODUCERS);
+
+ for (int p = 0; p < PRODUCERS; p++) {
+ final int producer = p;
+ Thread thread =
+ new Thread(
+ () -> {
+ await(start);
+ try {
+ for (int i = 0; i < PER_PRODUCER; i++) {
+ int value = producer * PER_PRODUCER + i;
+ if (queue.tryPut(value)) {
+ admitted.incrementAndGet();
+ } else {
+ refused.incrementAndGet();
+ }
+ }
+ } finally {
+ producersDone.countDown();
+ }
+ },
+ "producer-" + p);
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ AtomicBoolean consumerFailed = new AtomicBoolean();
+ Thread consumer =
+ new Thread(
+ () -> {
+ boolean producersFinished = false;
+ while (true) {
+ boolean hadWork = queue.process(value -> timesSeen.incrementAndGet(value));
+ if (hadWork) {
+ consumed.incrementAndGet();
+ } else if (producersFinished) {
+ return;
+ } else {
+ producersFinished = producersDone.getCount() == 0;
+ Thread.yield();
+ }
+ }
+ },
+ "consumer");
+ consumer.setDaemon(true);
+ consumer.setUncaughtExceptionHandler((t, e) -> consumerFailed.set(true));
+ consumer.start();
+
+ start.countDown();
+ assertTrue(
+ producersDone.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), "producers did not finish in time");
+ consumer.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
+ assertFalse(consumer.isAlive(), "consumer did not finish in time");
+ assertFalse(consumerFailed.get(), "consumer thread threw");
+
+ assertEquals(
+ admitted.get(), consumed.get(), "every admitted element reaches the consumer once");
+ assertEquals(
+ TOTAL - admitted.get(), refused.get(), "every rejection was reported to its producer");
+ assertEquals(0, queue.size());
+
+ for (int value = 0; value < TOTAL; value++) {
+ int seen = timesSeen.get(value);
+ assertTrue(seen <= 1, "element " + value + " was consumed " + seen + " times");
+ }
+ }
+
+ /**
+ * The reserve-before-construct guarantee under contention: producers race for a capacity that is
+ * never freed, so no producer may ever run.
+ */
+ @Test
+ void neverInvokesProducerWhileFull() throws Exception {
+ WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY);
+ while (queue.tryPut(0)) {
+ // fill it, and leave it full — nothing consumes
+ }
+ AtomicInteger produced = new AtomicInteger();
+ AtomicInteger refusedAfterFull = new AtomicInteger();
+ AtomicInteger admittedAfterFull = new AtomicInteger();
+ CountDownLatch start = new CountDownLatch(1);
+ CountDownLatch done = new CountDownLatch(PRODUCERS);
+
+ for (int p = 0; p < PRODUCERS; p++) {
+ Thread thread =
+ new Thread(
+ () -> {
+ await(start);
+ try {
+ for (int i = 0; i < PER_PRODUCER; i++) {
+ boolean landed =
+ queue.tryPut(
+ produced,
+ counter -> {
+ counter.incrementAndGet();
+ return 1;
+ });
+ if (landed) {
+ admittedAfterFull.incrementAndGet();
+ } else {
+ refusedAfterFull.incrementAndGet();
+ }
+ }
+ } finally {
+ done.countDown();
+ }
+ },
+ "producer-" + p);
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ start.countDown();
+ assertTrue(done.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), "producers did not finish in time");
+
+ assertEquals(0, admittedAfterFull.get(), "a full queue admits nothing");
+ assertEquals(0, produced.get(), "no element may be built for a slot that was never claimed");
+ assertEquals(
+ (long) PRODUCERS * PER_PRODUCER,
+ refusedAfterFull.get(),
+ "every rejected admission was reported to its producer");
+ }
+
+ /**
+ * Reservations mixed into ordinary admission under contention: the consumer has to tell a place
+ * that is still being filled from an element that is ready, and from a place that was abandoned,
+ * without losing or duplicating anything behind it.
+ */
+ @Test
+ void conservesElementsWhenProducersReserve() throws Exception {
+ WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY);
+ AtomicIntegerArray timesSeen = new AtomicIntegerArray(TOTAL);
+ AtomicInteger admitted = new AtomicInteger();
+ AtomicInteger consumed = new AtomicInteger();
+
+ CountDownLatch start = new CountDownLatch(1);
+ CountDownLatch producersDone = new CountDownLatch(PRODUCERS);
+
+ for (int p = 0; p < PRODUCERS; p++) {
+ final int producer = p;
+ Thread thread =
+ new Thread(
+ () -> {
+ await(start);
+ try {
+ for (int i = 0; i < PER_PRODUCER; i++) {
+ int value = producer * PER_PRODUCER + i;
+ switch (i % 3) {
+ case 0:
+ if (queue.tryPut(value)) {
+ admitted.incrementAndGet();
+ }
+ break;
+ case 1:
+ try (Reservation place = queue.tryReserve()) {
+ if (place.granted()) {
+ place.fill(value);
+ admitted.incrementAndGet();
+ }
+ }
+ break;
+ default:
+ // claimed and then abandoned: the consumer must skip it
+ try (Reservation place = queue.tryReserve()) {
+ // no fill
+ }
+ break;
+ }
+ }
+ } finally {
+ producersDone.countDown();
+ }
+ },
+ "producer-" + p);
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ AtomicBoolean consumerFailed = new AtomicBoolean();
+ Thread consumer =
+ new Thread(
+ () -> {
+ boolean producersFinished = false;
+ while (true) {
+ boolean hadWork = queue.process(value -> timesSeen.incrementAndGet(value));
+ if (hadWork) {
+ consumed.incrementAndGet();
+ } else if (producersFinished) {
+ return;
+ } else {
+ producersFinished = producersDone.getCount() == 0;
+ Thread.yield();
+ }
+ }
+ },
+ "consumer");
+ consumer.setDaemon(true);
+ consumer.setUncaughtExceptionHandler((t, e) -> consumerFailed.set(true));
+ consumer.start();
+
+ start.countDown();
+ assertTrue(
+ producersDone.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), "producers did not finish in time");
+ consumer.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
+ assertFalse(consumer.isAlive(), "consumer did not finish in time");
+ assertFalse(consumerFailed.get(), "consumer thread threw");
+
+ assertEquals(
+ admitted.get(), consumed.get(), "every filled place reaches the consumer exactly once");
+ assertEquals(0, queue.size(), "no abandoned place is left holding capacity");
+
+ for (int value = 0; value < TOTAL; value++) {
+ int seen = timesSeen.get(value);
+ assertTrue(seen <= 1, "element " + value + " was consumed " + seen + " times");
+ }
+ }
+
+ private static void await(CountDownLatch latch) {
+ try {
+ latch.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java
new file mode 100644
index 00000000000..617ec1401a4
--- /dev/null
+++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java
@@ -0,0 +1,1199 @@
+package datadog.common.queue;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.IntFunction;
+import java.util.stream.Stream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/** The behaviour every backing must share, exercised against each of them. */
+class WorkQueueContractTest {
+
+ private static final int CAPACITY = 4;
+
+ static Stream boundedQueues() {
+ return Stream.of(
+ Arguments.of("mpsc", (IntFunction>) WorkQueues::createMpscQueue),
+ Arguments.of("mpmc", (IntFunction>) WorkQueues::createMpmcQueue));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void admitsUpToCapacityThenRefuses(String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ for (int i = 0; i < CAPACITY; i++) {
+ assertTrue(queue.tryPut("e" + i));
+ }
+ assertEquals(CAPACITY, queue.size());
+ assertFalse(queue.tryPut("overflow"));
+ assertEquals(CAPACITY, queue.size());
+ }
+
+ /** The point of the whole API: a rejected element is never built. */
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void doesNotInvokeProducerWhenFull(String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ for (int i = 0; i < CAPACITY; i++) {
+ assertTrue(queue.tryPut("e" + i));
+ }
+ AtomicBoolean produced = new AtomicBoolean();
+ assertFalse(
+ queue.tryPut(
+ produced,
+ flag -> {
+ flag.set(true);
+ return "built";
+ }));
+ assertFalse(produced.get(), "producer ran for an element that could not be admitted");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void invokesProducerWhenThereIsRoom(String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ assertTrue(queue.tryPut("ctx", context -> context + "-built"));
+ List consumed = consumeAll(queue);
+ assertEquals(Arrays.asList("ctx-built"), consumed);
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void batchAdmissionReportsRejectedElements(String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ Collection rejected = queue.tryPutBatch("a", "b", "c", "d", "e", "f");
+ assertEquals(Arrays.asList("e", "f"), new ArrayList<>(rejected));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void collectionAdmissionReportsRejectedElements(
+ String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ Collection rejected = queue.tryPutBatch(Arrays.asList("a", "b", "c", "d", "e", "f"));
+ assertEquals(Arrays.asList("e", "f"), new ArrayList<>(rejected));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void transformingBatchAdmissionAppliesTheContextToEverySourceElement(
+ String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ int admitted =
+ queue.tryPutBatch(Arrays.asList(1, 2, 3), "x", (source, suffix) -> source + suffix);
+ assertEquals(3, admitted);
+ assertEquals(Arrays.asList("1x", "2x", "3x"), consumeAll(queue));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void aDeclinedSourceElementIsNotAdmitted(String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ // Every other element declined. Returning null is the caller's own decision, so it does not
+ // count against the admitted total: the caller already knows it declined.
+ int admitted =
+ queue.tryPutBatch(
+ Arrays.asList(1, 2, 3, 4, 5, 6),
+ "x",
+ (source, suffix) -> source % 2 == 0 ? null : source + suffix);
+ assertEquals(3, admitted);
+ assertEquals(Arrays.asList("1x", "3x", "5x"), consumeAll(queue));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void decliningLeavesTheClaimedPlaceAvailableToTheRestOfTheBatch(
+ String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ // Nearly twice capacity in source elements, the even ones declined: the place claimed for a
+ // declined element has to go back, or the batch would run out of room after CAPACITY source
+ // elements rather than after CAPACITY admitted ones.
+ int admitted =
+ queue.tryPutBatch(
+ Arrays.asList(1, 2, 3, 4, 5, 6, 7),
+ "x",
+ (source, suffix) -> source % 2 == 0 ? null : source + suffix);
+ assertEquals(CAPACITY, admitted);
+ assertEquals(Arrays.asList("1x", "3x", "5x", "7x"), consumeAll(queue));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void theShortfallIsExactWhenTheCallerKnowsWhatItMeantToAdmit(
+ String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ List asked = new ArrayList<>();
+ int intended = 6;
+ int admitted =
+ queue.tryPutBatch(
+ Arrays.asList(1, 2, 3, 4, 5, 6),
+ "x",
+ (source, suffix) -> {
+ asked.add(source);
+ return source + suffix;
+ });
+ assertEquals(CAPACITY, admitted);
+ // The whole point of the count: a caller that declined nothing gets its loss by subtraction.
+ assertEquals(2, intended - admitted);
+ // And the producer was only ever asked about elements there was already room for.
+ assertEquals(Arrays.asList(1, 2, 3, 4), asked);
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void aSourceElementTheProducerWouldHaveDeclinedIsStillRefusedOnceFull(
+ String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ // The odd elements fill the queue exactly, so element 8 never gets a place -- even though the
+ // producer would have declined it. The place is claimed before the producer is asked, so the
+ // queue cannot know that, and reports what is true from where it stands: it could not ask.
+ // This is why the reject handler is approximate for a declining producer and the shortfall
+ // by subtraction is not.
+ List refused = new ArrayList<>();
+ int admitted =
+ queue.tryPutBatch(
+ Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8),
+ "x",
+ (source, suffix) -> source % 2 == 0 ? null : source + suffix,
+ refused::add);
+ assertEquals(CAPACITY, admitted);
+ assertEquals(Arrays.asList("1x", "3x", "5x", "7x"), consumeAll(queue));
+ assertEquals(
+ Arrays.asList(8),
+ refused,
+ "element 8 was refused for want of a place, though it would have been declined");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void transformingBatchAdmissionAdmitsNothingOnceClosed(
+ String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ queue.close();
+ AtomicBoolean asked = new AtomicBoolean();
+ int admitted =
+ queue.tryPutBatch(
+ Arrays.asList(1, 2),
+ "x",
+ (source, suffix) -> {
+ asked.set(true);
+ return source + suffix;
+ });
+ assertEquals(0, admitted);
+ assertFalse(asked.get(), "a closed queue must not ask the producer for anything");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void aRejectHandlerSeesEverySourceElementThatCouldNotBeAdmitted(
+ String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ List rejected = new ArrayList<>();
+ int admitted =
+ queue.tryPutBatch(
+ Arrays.asList(1, 2, 3, 4, 5, 6),
+ "x",
+ (source, suffix) -> source + suffix,
+ rejected::add);
+ assertEquals(CAPACITY, admitted);
+ assertEquals(Arrays.asList(5, 6), rejected);
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void aRejectHandlerDoesNotSeeElementsTheProducerDeclined(
+ String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ List rejected = new ArrayList<>();
+ // Six source elements, three declined, three admitted -- the queue never fills, so nothing was
+ // refused and the handler is never called. A decline is not a rejection.
+ int admitted =
+ queue.tryPutBatch(
+ Arrays.asList(1, 2, 3, 4, 5, 6),
+ "x",
+ (source, suffix) -> source % 2 == 0 ? null : source + suffix,
+ rejected::add);
+ assertEquals(3, admitted);
+ assertTrue(rejected.isEmpty(), "a declined element is the caller's own decision");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void aThrowingTransformGivesBackItsPlaceAndPropagates(
+ String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ List source = Arrays.asList(1, 2, 3);
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ queue.tryPutBatch(
+ source,
+ "x",
+ (element, suffix) -> {
+ if (element == 2) {
+ throw new IllegalStateException("boom");
+ }
+ return element + suffix;
+ }));
+ // The place claimed for the failed element went back, so the queue still holds capacity for
+ // three more admissions beyond the one that succeeded.
+ assertEquals(1, queue.size());
+ assertTrue(queue.tryPutBatch("a", "b", "c").isEmpty());
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void exceptionHandlerSeesTheFailureAndTheItemIsDropped(
+ String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ queue.tryPut("a");
+
+ List seen = new ArrayList<>();
+ assertTrue(
+ queue.processOrHandle(
+ item -> {
+ throw new IllegalStateException("boom");
+ },
+ (item, failure) -> seen.add(item + ":" + failure.getMessage())));
+
+ assertEquals(Arrays.asList("a:boom"), seen, "the handler is told which item died");
+ assertEquals(0, queue.size());
+ assertFalse(queue.process(item -> fail("nothing should be left")));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void exceptionHandlerIsNotCalledWhenTheConsumerSucceeds(
+ String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ queue.tryPut("a");
+
+ List consumed = new ArrayList<>();
+ assertTrue(
+ queue.processOrHandle(
+ consumed::add,
+ (item, failure) -> fail("handler ran for a consumer that did not throw")));
+
+ assertEquals(Arrays.asList("a"), consumed);
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void processReportsWhetherThereWasWork(String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ assertFalse(queue.process(item -> {}), "empty queue has no work");
+ queue.tryPut("a");
+ assertTrue(queue.process(item -> {}));
+ assertFalse(queue.process(item -> {}));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void processPropagatesAConsumerFailureWhenGivenNoStrategy(
+ String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ queue.tryPut("a");
+
+ IllegalStateException thrown =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ queue.process(
+ item -> {
+ throw new IllegalStateException("boom");
+ }),
+ "without a strategy the queue takes no view on failure");
+
+ assertEquals("boom", thrown.getMessage());
+ assertEquals(0, queue.size(), "the item was still consumed off the queue");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void processReportsWorkEvenWhenTheStrategyGivesUp(
+ String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ queue.tryPut("a");
+ RetryStrategy giveUp = (item, attempt, failure, retryQueue) -> false;
+ assertTrue(
+ queue.processOrRetry(
+ item -> {
+ throw new IllegalStateException("boom");
+ },
+ giveUp),
+ "the return value reports work found, not consumer success");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void retriesUntilTheStrategyGivesUp(String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ queue.tryPut("a");
+ AtomicInteger attempts = new AtomicInteger();
+ List reported = new ArrayList<>();
+
+ RetryStrategy strategy =
+ (item, attempt, failure, retryQueue) -> {
+ reported.add(attempt);
+ return attempt < 2 && retryQueue.retry(item);
+ };
+
+ while (queue.processOrRetry(
+ item -> {
+ attempts.incrementAndGet();
+ throw new IllegalStateException("boom");
+ },
+ strategy)) {
+ // drain until the strategy stops resubmitting
+ }
+
+ assertEquals(2, attempts.get(), "consumed twice: original plus one retry");
+ assertEquals(Arrays.asList(1, 2), reported, "attempt counts survive re-admission");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void maxRetriesBoundsResubmission(String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ queue.tryPut("a");
+ AtomicInteger attempts = new AtomicInteger();
+ RetryStrategy strategy = new MaxRetries<>(3);
+
+ while (queue.processOrRetry(
+ item -> {
+ attempts.incrementAndGet();
+ throw new IllegalStateException("boom");
+ },
+ strategy)) {
+ // drain
+ }
+
+ assertEquals(4, attempts.get(), "three retries on top of the original consumption");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void closeStopsAdmissionButKeepsBacklog(String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ queue.tryPut("a");
+ queue.close();
+
+ assertTrue(queue.isClosed());
+ assertFalse(queue.tryPut("b"));
+ assertEquals(1, queue.size(), "already-admitted work survives so a consumer can finish");
+ assertEquals(Arrays.asList("a"), consumeAll(queue));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void clearDiscardsContentsButLeavesAdmissionOpen(
+ String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ queue.tryPutBatch("a", "b");
+ queue.clear();
+
+ assertEquals(0, queue.size());
+ assertFalse(queue.isClosed());
+ assertTrue(queue.tryPut("c"), "clear does not close");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("boundedQueues")
+ void shutdownClosesAndDiscards(String name, IntFunction> factory) {
+ WorkQueue queue = factory.apply(CAPACITY);
+ queue.tryPutBatch("a", "b");
+ queue.shutdown();
+
+ assertEquals(0, queue.size());
+ assertTrue(queue.isClosed());
+ assertFalse(queue.tryPut("c"));
+ }
+
+ @org.junit.jupiter.api.Test
+ void aBoundedMpmcQueueRoundsItsCapacityUpToTheRing() {
+ // The bound is the ring's own capacity, not the number asked for, so the counter and the ring
+ // can never disagree about what full means. A caller asking for 100 gets 128.
+ WorkQueue queue = WorkQueues.createMpmcQueue(100);
+ for (int i = 0; i < 128; i++) {
+ assertTrue(queue.tryPut("e" + i), "place " + i + " should have been there");
+ }
+ assertFalse(queue.tryPut("overflow"));
+ assertEquals(128, queue.size());
+ }
+
+ @org.junit.jupiter.api.Test
+ void aBoundedMpmcQueueTooSmallForTheRingIsRaisedRatherThanRefused() {
+ // JCTools will not build a ring of one. Rounding up is the established answer to a capacity
+ // the ring cannot honour, and throwing here would only surprise a caller who asked for less.
+ WorkQueue