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..c8991d767c6 --- /dev/null +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionAlternativesBenchmark.java @@ -0,0 +1,286 @@ +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.MessagePassingQueue; +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. Reach for {@link WorkQueue} for its guarantees: an element is built, or a destructive + * read made, only once capacity is claimed, through every admission form including batches and + * reservations; refusal is reported rather than swallowed; the queue has a lifecycle; and consumer + * failures can be retried or handled. The numbers below are what those guarantees cost. They are + * not a claim that the API is faster. + * + *

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 @Fork(2)}, {@code -Pjmh.profilers=gc}. + * + *

+ * Benchmark                 threads    ns/op   B/op
+ * steadyRawMpscFill               1     22.9     24
+ * steadyRawMpsc                   1     23.8     24
+ * steadyWorkQueue                 1     35.8     24
+ * steadyArrayBlocking             1     38.0     24
+ * steadyClqWithCounter            1     42.1     48
+ * steadyLinkedBlocking            1     45.0     48
+ * refusedWorkQueue                4      1.5      0
+ * refusedRawMpscFill              4      2.6      0
+ * refusedLinkedBlocking           4    139.9     24
+ * refusedRawMpsc                  4    142.0     24
+ * refusedClqWithCounter           4    149.6     24
+ * refusedArrayBlocking            4    184.0     24
+ * 
+ * + *

Admitting. The raw ring is the floor at about 23ns, with or without {@code fill}. + * {@link WorkQueue} costs about 13ns more for the permit counter and the producer callback, and no + * more allocation: that is the price of the guarantees above. The incumbents that build first cost + * more than that anyway, and the two linked queues allocate a node per element on top of the + * element itself. + * + *

Refusing. Every arm that builds before it asks lands between 140 and 185ns with 24 + * B/op, and the gap to the claim-first arms is the element, not the queue: the same ring that takes + * 142ns to refuse through {@code offer} refuses in 2.6ns through {@code fill}, which also claims a + * slot before calling its supplier. {@code refusedWorkQueue} is cheaper still, because refusal is a + * load against a counter no refusing thread writes -- see {@code ContendedAdmissionBenchmark}. A + * caller whose element already exists, or is free to build, keeps the ordering of this gap and not + * its size. + */ +@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()); + + /** + * The same build for the ring's own {@code fill}, which also claims a slot before calling it. It + * must not throw or return null: either would leave a claimed slot the consumer waits on forever. + */ + private static final MessagePassingQueue.Supplier RAW_BUILDER = + () -> new Payload(ELEMENT, System.nanoTime()); + + private MpscArrayQueue steadyRaw; + private MessagePassingQueue steadyRawFill; + private BlockingQueue steadyArrayBlocking; + private BlockingQueue steadyLinkedBlocking; + private Queue steadyClq; + private AtomicInteger steadyClqCount; + private WorkQueue steadyWork; + + private MpscArrayQueue fullRaw; + private MessagePassingQueue fullRawFill; + private BlockingQueue fullArrayBlocking; + private BlockingQueue fullLinkedBlocking; + private Queue fullClq; + private AtomicInteger fullClqCount; + private WorkQueue fullWork; + + @Setup + public void setUp() { + steadyRaw = new MpscArrayQueue<>(CAPACITY); + steadyRawFill = Queues.mpscArrayQueue(CAPACITY); + steadyArrayBlocking = new ArrayBlockingQueue<>(CAPACITY); + steadyLinkedBlocking = new LinkedBlockingQueue<>(CAPACITY); + steadyClq = new ConcurrentLinkedQueue<>(); + steadyClqCount = new AtomicInteger(); + steadyWork = WorkQueues.createMpscQueue(CAPACITY); + + fullRaw = new MpscArrayQueue<>(16); + fullRawFill = Queues.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); + fullRawFill.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()); + } + + /** + * The ring claiming before it builds, through the same factory {@link WorkQueue} uses. Against + * {@code steadyWorkQueue}, this is what the permit counter and the API cost on top of it. + */ + @Benchmark + @Threads(1) + public void steadyRawMpscFill(Blackhole bh) { + bh.consume(steadyRawFill.fill(RAW_BUILDER, 1)); + bh.consume(steadyRawFill.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); + } + + /** The ring's own claim-before-build: a full ring returns 0 without calling the supplier. */ + @Benchmark + @Threads(4) + public void refusedRawMpscFill(Blackhole bh) { + bh.consume(fullRawFill.fill(RAW_BUILDER, 1)); + } + + /** 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..2cdabd649b1 --- /dev/null +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionBenchmark.java @@ -0,0 +1,231 @@ +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; + +/** + * Compares admission APIs and measures whether HotSpot removes the temporary reservation object + * through escape analysis. + * + *

If the reservation is eliminated, the reserve path is allocation-free and costs what the + * producer overloads cost, so {@link BiContextualProducer} would not have been needed for + * client-side stats. If it survives, the reserve path allocates per admission and the producer + * overloads earn their place. Compare its GC profiler results with those of the producer overloads. + * + *

./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 MegamorphicCallSiteWorkQueue} 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 MegamorphicCallSiteWorkQueue<>(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..6b5ca2fc1f1 --- /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} measured 252.8ns per attempt on MPMC and 489.7ns on MPSC in this run. The timed + * operation is an admission attempt, including refusals; a separate, untimed thread drains the + * queue. These values do not isolate drain cost or successful handoff throughput. Both backings + * support this producer/consumer arrangement. + * + *

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; + + /** Queue drained concurrently by the dedicated {@link #drain} thread. */ + 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/MegamorphicCallSiteWorkQueue.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/MegamorphicCallSiteWorkQueue.java new file mode 100644 index 00000000000..5212656a117 --- /dev/null +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/MegamorphicCallSiteWorkQueue.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}, making those call sites megamorphic. + * + *

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 MegamorphicCallSiteWorkQueue extends BaseWorkQueue { + + private final MessagePassingQueue queue; + + MegamorphicCallSiteWorkQueue(int requestedCapacity) { + this(Queues.spscArrayQueue(requestedCapacity)); + } + + private MegamorphicCallSiteWorkQueue(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/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/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..7a0de8b5cfc --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -0,0 +1,829 @@ +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); + + /** + * Maximum places reserved by one batch claim. + * + *

A claim lowers the shared permit count before its elements are stored or unused permits are + * returned. Other producers can be refused during that interval. This cap limits how much + * capacity one claim can withhold; it does not bound the number of refusals or guarantee + * fairness. Each claim is also clamped to the available-count snapshot. + * + *

{@code ContendedBatchAdmissionBenchmark} compares batching with single admissions and + * measures the effect on neighbouring producers. + */ + 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 after capacity has been claimed. + * + *

A false result tells the caller to release the claim. The producer and reservation paths + * then discard the built element. Neither shipped backing returns false: claims never exceed the + * capacity of the structure underneath. + * + *

This virtual call site is shared by all backings. With a third backing, C2 did not inline + * store or retrieve on JDK 17 or 25. The JDK 25 AdmissionBenchmark measured about 1-2 ns more for + * ordinary admission and about 30% more for reservation fill, without additional allocation. + * Measure this path before adding another backing. Keeping two storage structures behind one + * backing type does not add a receiver type at this call site. + * + * @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(); + } + + /** + * Claims up to {@code wanted} places, allowing a partial grant. + * + *

One atomic subtraction claims the amount suggested by the available-count snapshot. If + * concurrent claims overspend the count, a second atomic update returns this claim's deficit. + * There is no compare-and-set retry loop. + * + *

Read {@link #state} without removing its closed bias: a closed queue must grant nothing. If + * {@link #close()} races with the subtraction, the full claim is returned, preserving the bias. + * + *

A partial grant does not prove the queue is full: other claimants may return permits. + * Callers continue claiming until a claim returns zero. + * + * @param wanted the maximum number of places to claim + * @return the places granted, from zero to {@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 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 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 producer, + @Strategy RejectHandler 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 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: if a backing ever + // refuses an element it had claimed room for, the place has to come back exactly once. + queue.storeOrRelease(element); + } + + @Override + public void close() { + // Reservation confines fill and close to the reserving thread, so a plain flag suffices. + 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 producer) { + return admit(producer, (ContextualProducer) PRODUCE); + } + + @Override + public final boolean tryPut(C context, ContextualProducer producer) { + return admit(context, producer); + } + + @Override + public final boolean tryPut( + C1 first, C2 second, BiContextualProducer 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 elements) { + List rejected = null; + int remaining = elements.size(); + Iterator 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 source, + C context, + BiContextualProducer 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 source, + C context, + BiContextualProducer producer, + RejectHandler onRejected) { + int admitted = 0; + int remaining = source.size(); + Iterator 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 consumer) { + return processOrRetry(consumer, null); + } + + @Override + public final boolean processOrHandle( + Consumer consumer, ExceptionHandler exceptionHandler) { + Object raw = take(); + if (raw == null) { + return false; + } + consume(raw, consumer, null, null, null, exceptionHandler); + return true; + } + + @Override + public final boolean processOrRetry( + Consumer 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 consumer) { + return processOrRetry(context, consumer, null); + } + + @Override + public final boolean processOrRetry( + C context, BiConsumer 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 consumer, + ExceptionHandler 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 consumer) { + return process(limit, consumer, null, null); + } + + @Override + public final int process(int limit, C context, BiConsumer consumer) { + return process(limit, null, context, consumer); + } + + private int process( + int limit, + Consumer consumer, + C context, + BiConsumer 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 consumer, + C context, + BiConsumer biConsumer, + RetryStrategy retryStrategy, + ExceptionHandler 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..96a5231067b --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MaxRetries.java @@ -0,0 +1,21 @@ +package datadog.common.queue; + +/** + * The strategy behind {@link RetryStrategy#maxRetries}. A named class rather than a lambda so it + * reads as itself in compilation logs. + * + *

{@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. + */ +final class MaxRetries implements RetryStrategy { + private final int maxRetries; + + 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..64d2cb62298 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MpmcWorkQueue.java @@ -0,0 +1,74 @@ +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. Its strict {@code offer} refuses only when the ring's indexes say it is full, and + * places outstanding never exceed its capacity, so {@link #store} cannot be refused: a slot a + * consumer has claimed but not yet released is waited out inside {@code offer}, not reported. + */ +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; + + 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) { + return queue.offer(element); + } + + @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..55592d78386 --- /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 after a {@link WorkQueue} has claimed capacity for it. + * + *

Prefer a reusable producer or a non-capturing lambda. The contextual producer overloads carry + * per-admission state without capturing it. Constructing a capturing lambda for each admission may + * allocate; HotSpot can eliminate that allocation through escape analysis when the call chain + * inlines. {@link Strategy} describes the intended optimisation pattern, without guaranteeing it. + * + *

Use {@link WorkQueue#tryReserve()} when the work between claiming and filling cannot fit a + * producer callback. A producer returning null declines the element and releases its claimed place. + * + * @param the produced item type + */ +@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..a07f0fee1af 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; @@ -22,6 +24,10 @@ * performance and without relying on {@code sun.misc.Unsafe}. *

  • For Java 8, {@code JCTools}-based wrappers are used instead. * + * + *

    Use these factories for direct JCTools queue operations like {@code offer}, {@code poll}, or + * {@code fill}. Use {@link WorkQueues} for cancellable reservations, batch capacity claims, + * closeable queues, or processing with retry strategies. */ public final class Queues { @@ -43,6 +49,27 @@ public static MessagePassingQueue mpscArrayQueue(int requestedCapacity) { return new MpscArrayQueue<>(requestedCapacity); } + /** + * Creates a Multiple Producer, Multiple Consumer (MPMC) array-backed queue. + * + *

    {@code offer} refuses when the ring's indexes say it is full, and a slot claimed by a + * producer that has not finished publishing counts as occupied. A caller that tracks occupancy + * itself, by counting completed offers and polls, will therefore see refusals while its own count + * says there is room: measured at roughly 0.24% of offers with four producers and four consumers + * on a queue of eight. {@link WorkQueues#createMpmcQueue} avoids this by claiming capacity before + * it offers. + * + * @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..ee18751d4e7 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/RejectHandler.java @@ -0,0 +1,20 @@ +package datadog.common.queue; + +import datadog.trace.api.function.Strategy; + +/** + * Receives source elements a batch could not admit. + * + *

    A producer returning null declines an element, so the handler is not called for it. If + * capacity runs out before the producer is invoked, the handler may also receive elements the + * producer would have declined. Reapply that rule before resubmitting them. + * + * @param the source element type + * @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..add79a425d6 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/Reservation.java @@ -0,0 +1,62 @@ +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 reservation is confined to the thread that reserved it: only that thread may fill or close + * it. Completion is not synchronized, so filling or closing from another thread can store two + * elements against one place, or store an element whose place was already given back. + * + *

    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 reservation ignores the fill and counts nothing: check {@link + * #granted} and count any discarded element yourself. + */ + 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..721eb60d11d --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/RetryStrategy.java @@ -0,0 +1,37 @@ +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); + + /** + * Resubmits a failed item up to {@code maxRetries} times, then gives up. The count is retries, + * not consumptions: {@code maxRetries(3)} lets an item be consumed four times in all, and {@code + * maxRetries(0)} never resubmits. + * + *

    Hold the result in a {@code static final} field, or call this inline at the processing call + * site; either way the strategy's exact class is visible to the compiler. + * + * @param the item type + * @param maxRetries how many times an item may be resubmitted + * @return a strategy that retries at most {@code maxRetries} times + */ + static RetryStrategy maxRetries(int maxRetries) { + return new MaxRetries<>(maxRetries); + } +} 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..d4c60e695eb --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -0,0 +1,273 @@ +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; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * A handoff queue that claims capacity before invoking a producer. + * + *

    A full or closed queue does not invoke the producer. This lets callers defer construction and + * destructive reads until capacity is available. {@link #tryPut(Object)} accepts work already built + * by the caller. Bounded factories fix capacity at construction; an unbounded queue requires {@link + * WorkQueues#createUnboundedMpmcQueue()}. + * + *

    A claim reserves capacity without holding a position in the backing while the element is + * built. Keep producer callbacks short: their claims withhold capacity from other producers. Prefer + * producer callbacks; use {@link #tryReserve()} when the work cannot fit one. Check {@link + * Reservation#granted()} before building and close unfilled reservations to release capacity. + * + *

    Admission reports refusals through return values or an optional {@link RejectHandler}. Batch + * overloads return admitted counts or rejected elements. The queue keeps no drop count; callers + * handle the outcomes they need to account for. + * + *

    Processing runs on the calling thread. Boolean results report whether an item was found; they + * say nothing about whether the consumer succeeded. Plain processing propagates consumer failures; + * the retry and handler variants delegate them to a {@link RetryStrategy} or {@link + * ExceptionHandler}. + * + *

    Elements must be non-null and are checked before any capacity is claimed. Producers, + * consumers, retry strategies and exception handlers must be non-null too, but are marked {@link + * Nonnull} rather than checked; a null function object is a caller bug with unspecified effect. + * Contexts and the optional reject handler may be null. A producer may return null to decline work + * and release its claim. Single-element producer overloads return false for both declines and + * refusals. A null element in a batch throws and abandons the remaining elements; earlier + * admissions remain. + * + * @param the work item type + */ +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(@Nonnull @Strategy Producer 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, @Nonnull @Strategy ContextualProducer 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, + @Nonnull @Strategy BiContextualProducer 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 elements); + + /** + * Transforms source elements after claiming capacity for each one. + * + *

    A null result declines a source element and releases its claim. If no claim is granted, the + * producer is not called for the remaining source elements. The difference between the source + * size and the admitted count therefore includes both declines and refusals. Use a caller-owned + * loop if every source element needs processing regardless of admission. + * + *

    The source is a collection to require a finite batch: concurrent consumers could keep making + * room indefinitely for an unbounded iterable. + * + * @param the source element type + * @param the shared context type + * @param source elements to transform + * @param context value shared by producer calls; may be null + * @param producer creates an element for each granted claim; may return null to decline + * @return the number of elements admitted + */ + @StrategyConsumer + int tryPutBatch( + Collection source, + C context, + @Nonnull @Strategy BiContextualProducer 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 source, + C context, + @Nonnull @Strategy BiContextualProducer producer, + @Nullable @Strategy RejectHandler onRejected); + + /** + * Claims capacity for work that cannot be built in a producer callback. + * + *

    The reservation holds no position in the backing. Its element joins the queue when filled, + * so an open reservation does not block consumers. Use try-with-resources to release unfilled + * reservations; abandoning one permanently consumes its capacity. Keep the reservation on the + * reserving thread. + * + *

    A refused claim returns a non-null reservation that ignores fill. Check {@link + * Reservation#granted()} before building the element. + * + * @return a granted or refused reservation + */ + Reservation tryReserve(); + + /** + * Consumes one item, if there is one. A throwing consumer propagates. + * + * @return whether there was an item to consume + */ + boolean process(@Nonnull Consumer 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( + @Nonnull Consumer consumer, @Nonnull @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( + @Nonnull Consumer consumer, + @Nonnull @Strategy ExceptionHandler exceptionHandler); + + /** + * Consumes one item, if there is one. A throwing consumer propagates. + * + * @return whether there was an item to consume + */ + boolean process(C context, @Nonnull BiConsumer 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, + @Nonnull BiConsumer consumer, + @Nonnull @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, + @Nonnull BiConsumer consumer, + @Nonnull @Strategy ExceptionHandler 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, @Nonnull Consumer 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, @Nonnull BiConsumer 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(); + + /** + * Closes admission and discards queued elements. + * + *

    This operation is not atomic. Producers, reservations, and retries that claimed capacity + * before the close may still store after the discard. Quiesce them first if the queue must be + * empty afterward. + */ + 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..6ab321ae9bf --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java @@ -0,0 +1,61 @@ +package datadog.common.queue; + +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * Creates {@link WorkQueue} instances that claim capacity before invoking a producer. + * + *

    Use these factories for cancellable reservations, batch capacity claims, closeable queues, or + * processing with retry strategies. Callers remain responsible for counting refusals. + * + *

    Use {@link Queues} for direct JCTools queue operations. The backing of a queue created here is + * an implementation detail, so a call site can be re-backed without changing. + */ +public final class WorkQueues { + + private WorkQueues() {} + + /** + * Creates a bounded array-backed queue for multiple producers and one consumer. + * + *

    Use one consumer thread for all operations that remove elements, including processing, + * {@link WorkQueue#clear()} and {@link WorkQueue#shutdown()}. Concurrent consumers can spin + * indefinitely. Use {@link #createMpmcQueue(int)} when several consumers are needed. + * + * @param the element type + * @param requestedCapacity capacity, rounded up to a power of two + * @return a queue requiring one consumer + */ + public static WorkQueue createMpscQueue(int requestedCapacity) { + return new MpscWorkQueue<>(requestedCapacity); + } + + /** + * Creates a bounded array-backed queue for multiple producers and consumers. + * + *

    Prefer {@link #createMpscQueue(int)} when one consumer is sufficient. + * + * @param the element type + * @param requestedCapacity capacity, rounded up to a power of two and at least two + * @return a queue that allows concurrent consumers + */ + 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..4cc9c358a1f --- /dev/null +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -0,0 +1,1358 @@ +package datadog.common.queue; + +import static datadog.common.queue.RetryStrategy.maxRetries; +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.api.Test; +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 = 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")); + } + + @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()); + } + + @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 queue = WorkQueues.createMpmcQueue(1); + assertTrue(queue.tryPut("a")); + assertTrue(queue.tryPut("b")); + assertFalse(queue.tryPut("c")); + } + + @Test + void severalProducersAndConsumersOnAnArrayRingLoseNothingAndLeakNoPlace() + throws InterruptedException { + // Admission claims a place before it stores, and places outstanding never exceed the ring's + // capacity, so the ring's offer should never refuse a claimed element. If that reasoning is + // wrong, it is wrong here: elements go missing without any producer being told, or their + // places never come back. Poll spins for a pending publish rather than reporting empty, so a + // false from process here means another consumer got there first. + int capacity = 64; + int producers = 4; + int consumers = 4; + int perProducer = 20_000; + WorkQueue queue = WorkQueues.createMpmcQueue(capacity); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger admitted = new AtomicInteger(); + AtomicInteger refused = new AtomicInteger(); + AtomicInteger consumed = new AtomicInteger(); + AtomicBoolean draining = new AtomicBoolean(true); + List threads = new ArrayList<>(); + for (int c = 0; c < consumers; c++) { + Thread drain = + new Thread( + () -> { + while (draining.get()) { + if (queue.process(item -> consumed.incrementAndGet())) { + continue; + } + Thread.yield(); + } + // Once the producers are done, take what is left. + while (queue.process(item -> consumed.incrementAndGet())) { + // drain to empty + } + }); + threads.add(drain); + drain.start(); + } + List admitters = new ArrayList<>(); + for (int p = 0; p < producers; p++) { + Thread admit = + new Thread( + () -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + for (int i = 0; i < perProducer; i++) { + if (queue.tryPut("e" + i)) { + admitted.incrementAndGet(); + } else { + refused.incrementAndGet(); + } + } + }); + admitters.add(admit); + admit.start(); + } + start.countDown(); + for (Thread admit : admitters) { + admit.join(SECONDS.toMillis(30)); + assertFalse(admit.isAlive(), "producer did not finish"); + } + draining.set(false); + for (Thread drain : threads) { + drain.join(SECONDS.toMillis(30)); + assertFalse(drain.isAlive(), "consumer did not finish"); + } + assertEquals( + producers * perProducer, + admitted.get() + refused.get(), + "every element was either admitted or refused, and the refusal was reported"); + assertEquals(admitted.get(), consumed.get(), "every admitted element came back out"); + // The decisive one: if a claimed store had been refused and the place had not come back, or a + // spurious empty read had stranded an element, the queue would now hold fewer than capacity + // places. + for (int i = 0; i < capacity; i++) { + assertTrue(queue.tryPut("after" + i), "place " + i + " was lost"); + } + assertFalse(queue.tryPut("overflow")); + } + + @Test + void unboundedQueueNeverRejects() { + WorkQueue queue = WorkQueues.createUnboundedMpmcQueue(); + for (int i = 0; i < 1000; i++) { + assertTrue(queue.tryPut("e" + i)); + } + assertEquals(1000, queue.size()); + } + + @Test + void unboundedQueueStillCloses() { + WorkQueue queue = WorkQueues.createUnboundedMpmcQueue(); + queue.close(); + assertFalse(queue.tryPut("a")); + } + + /** + * Closing is a bias applied to the permit count rather than a flag beside it, so the three ways + * that encoding could leak are worth pinning: applying it twice, reading a size through it, and + * giving places back underneath it. + * + *

    Two of the three pin behaviour they cannot currently catch a slip in, and it is worth being + * straight about why: the offset is far enough from either threshold that neither repeated closes + * nor a full queue's worth of returned places can reach it. They are guards against a future + * change to the offset or to the width of either. + * + *

    The size case is different now that {@code size()} clamps to the capacity. The offset is a + * multiple of 2^32, so the cast back to {@code int} used to erase the bias whether or not the + * unbiasing was there, and this test passed either way; a clamped report turns a missing unbias + * into a full-looking queue instead, which is a number this test rejects. + */ + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void closingTwiceSaysWhatClosingOnceSaid(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + + queue.close(); + queue.close(); + queue.close(); + + assertTrue(queue.isClosed(), "still closed, not closed three times over"); + assertFalse(queue.tryPut("a")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aClosedQueueStillReportsWhatItHolds(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + assertTrue(queue.tryPut("a")); + assertTrue(queue.tryPut("b")); + + queue.close(); + + assertEquals(2, queue.size(), "closing must not be visible as a size"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void drainingAfterCloseDoesNotReopen(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i)); + } + queue.close(); + + // Every drained element hands a place back, so a full queue's worth of releases runs the + // count as far back toward the bias as it can go. + List drained = new ArrayList<>(); + while (queue.process(drained::add)) { + // drain it dry + } + + assertEquals(CAPACITY, drained.size(), "close does not stop consumption"); + assertEquals(0, queue.size()); + assertTrue(queue.isClosed(), "returned places must not climb out of the closed state"); + assertFalse(queue.tryPut("after")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void retryCanPartitionFailedWorkIntoSeveralItems( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPut("ab"); + List consumed = new ArrayList<>(); + RetryStrategy split = + (item, attempt, failure, retryQueue) -> retryQueue.retry("a", "b"); + + while (queue.processOrRetry( + item -> { + if (item.length() > 1) { + throw new IllegalStateException("too big to handle in one piece"); + } + consumed.add(item); + }, + split)) { + // drain until the pieces are through + } + + assertEquals(Arrays.asList("a", "b"), consumed); + } + + // A reservation claims capacity, not a position: the element joins the queue when it is filled. + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void reservationClaimsCapacityUpFront(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + try (Reservation place = queue.tryReserve()) { + assertNotNull(place); + assertEquals(1, queue.size(), "the claim costs capacity before the element exists"); + for (int i = 0; i < CAPACITY - 1; i++) { + assertTrue(queue.tryPut("e" + i)); + } + assertFalse(queue.tryPut("overflow"), "the claimed place is not available to anyone else"); + place.fill("reserved"); + } + assertTrue( + consumeAll(queue).contains("reserved"), "filling a claimed place cannot be rejected"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void abandonedReservationYieldsNothingAndGivesTheCapacityBack( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + Reservation place = queue.tryReserve(); + assertNotNull(place); + place.close(); + + // Closing an unfilled reservation gives its capacity back at once, and nothing is ever consumed + // for it. + assertTrue(consumeAll(queue).isEmpty(), "an abandoned place produces no element"); + assertEquals(0, queue.size()); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i), "the abandoned capacity is usable again"); + } + assertEquals(CAPACITY, consumeAll(queue).size()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void reserveFailsWhenThereIsNoRoom(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i)); + } + Reservation refused = queue.tryReserve(); + assertFalse(refused.granted(), "a refusal is a reservation, never null"); + + refused.fill("discarded"); + refused.close(); + assertEquals(CAPACITY, queue.size(), "filling a refusal changes nothing and does not throw"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void reserveFailsOnceClosed(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.close(); + assertFalse(queue.tryReserve().granted()); + } + + /** A reservation holds no position, so the element joins the queue in fill order. */ + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void reservationJoinsWhereItIsFilledRatherThanWhereItWasClaimed( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPut("first"); + List consumed = new ArrayList<>(); + + try (Reservation place = queue.tryReserve()) { + assertTrue(queue.tryPut("behind"), "the rest of the queue stays open for admission"); + assertEquals( + Arrays.asList("first", "behind"), + consumeAll(queue), + "a reservation holds no position, so nothing is held in front of the consumer"); + place.fill("filled late"); + } + + consumed.addAll(consumeAll(queue)); + assertEquals(Arrays.asList("filled late"), consumed, "the order is the fill order"); + } + + /** + * The hazard a position-holding reservation would have: one thread that reserves and then drains + * would be waiting on itself. + */ + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aThreadMayReserveAndConsume(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPut("waiting"); + + try (Reservation place = queue.tryReserve()) { + assertEquals(1, queue.process(10, item -> {}), "consumption is not blocked by the claim"); + place.fill("filled"); + } + + assertEquals(Arrays.asList("filled"), consumeAll(queue)); + } + + @Test + void unboundedReservationAlwaysSucceeds() { + WorkQueue queue = WorkQueues.createUnboundedMpmcQueue(); + for (int i = 0; i < 1000; i++) { + try (Reservation place = queue.tryReserve()) { + assertNotNull(place); + place.fill("e" + i); + } + } + assertEquals(1000, queue.size()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void processStopsAtTheLimit(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + for (int i = 0; i < CAPACITY; i++) { + queue.tryPut("e" + i); + } + List consumed = new ArrayList<>(); + + assertEquals(2, queue.process(2, consumed::add)); + + assertEquals(Arrays.asList("e0", "e1"), consumed); + assertEquals(CAPACITY - 2, queue.size(), "the rest of the batch is still there"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void processStopsWhenTheQueueRunsDry(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPut("a"); + queue.tryPut("b"); + List consumed = new ArrayList<>(); + + assertEquals( + 2, + queue.process(100, consumed::add), + "a count short of the limit is how a caller learns there is no more work"); + + assertEquals(Arrays.asList("a", "b"), consumed); + assertEquals(0, queue.process(100, consumed::add), "and an empty queue drains nothing"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void processConsumesNothingForAnEmptyBatch(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPut("a"); + + assertEquals(0, queue.process(0, item -> fail("nothing may be consumed"))); + + assertEquals(1, queue.size()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void processPassesTheContextToEveryItem(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPut("a"); + queue.tryPut("b"); + List consumed = new ArrayList<>(); + + assertEquals(2, queue.process(10, consumed, List::add)); + + assertEquals(Arrays.asList("a", "b"), consumed); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void processAbandonsTheRestOfTheBatchWhenTheConsumerThrows( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPut("a"); + queue.tryPut("b"); + queue.tryPut("c"); + List consumed = new ArrayList<>(); + + assertThrows( + IllegalStateException.class, + () -> + queue.process( + 10, + item -> { + consumed.add(item); + if ("b".equals(item)) { + throw new IllegalStateException("boom"); + } + })); + + assertEquals(Arrays.asList("a", "b"), consumed, "the failing item was handed over"); + assertEquals(1, queue.size(), "what was behind it is left for the next drain"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void openReservationHoldsCapacityWithoutHoldingUpTheBatch( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPut("first"); + List consumed = new ArrayList<>(); + + try (Reservation place = queue.tryReserve()) { + queue.tryPut("behind"); + + assertEquals(2, queue.process(10, consumed::add), "the batch runs past the open claim"); + assertEquals(Arrays.asList("first", "behind"), consumed); + assertEquals(1, queue.size(), "the claimed place is still spent"); + + place.fill("reserved"); + } + + assertEquals(1, queue.process(10, consumed::add)); + assertEquals(Arrays.asList("first", "behind", "reserved"), consumed); + assertEquals(0, queue.size()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void admitsFromTwoContexts(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + + assertTrue(queue.tryPut("a", "b", (first, second) -> first + second)); + + assertEquals(Arrays.asList("ab"), consumeAll(queue)); + } + + /** The point of the whole API, in its two-context form: a rejected element is never built. */ + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void doesNotInvokeTwoContextProducerWhenFull( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + for (int i = 0; i < CAPACITY; i++) { + queue.tryPut("e" + i); + } + AtomicBoolean produced = new AtomicBoolean(); + + assertFalse( + queue.tryPut( + produced, + "unused", + (flag, ignored) -> { + flag.set(true); + return "built"; + })); + + assertFalse(produced.get(), "a full queue must not build what it is going to reject"); + } + + // --- What a null means, one test per place it can appear. --- + + /** + * The leak this guards against is silent and permanent: claiming a place and then throwing out of + * the backing would shrink capacity by one for the life of the queue, once per call. + */ + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aNullElementThrowsWithoutSpendingAPlace( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + // A null-valued variable, not a literal: a bare tryPut(null) does not compile, because it + // cannot tell tryPut(T) from tryPut(Producer). Real callers reach this path through a field. + String absent = null; + assertThrows(NullPointerException.class, () -> queue.tryPut(absent)); + assertEquals(0, queue.size()); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i), "the refused call must not have cost the queue a place"); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aNullElementThrowsOutOfABatchAndAbandonsTheRest( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + assertThrows( + NullPointerException.class, () -> queue.tryPutBatch(Arrays.asList("a", null, "b"))); + assertEquals(Arrays.asList("a"), consumeAll(queue), "what came before the null is admitted"); + // A batch claims places for a run of elements before it looks at any of them, so the throw + // leaves places claimed for the null and for everything behind it. They have to come back, or + // every null costs the queue room permanently. + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i), "the throw must not have cost the queue a place"); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void fillingAReservationWithNullThrowsAndTheReservationStillReleases( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + Reservation place = queue.tryReserve(); + assertTrue(place.granted()); + assertThrows(NullPointerException.class, () -> place.fill(null)); + place.close(); + assertEquals(0, queue.size()); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i)); + } + } + + /** + * A producer declining means the same thing in the single-element forms as it does in a batch: + * nothing was lost, so nothing is counted. The place has to come back either way. + */ + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aProducerDecliningIsNotAdmitted(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + assertFalse(queue.tryPut(() -> null)); + assertFalse(queue.tryPut("ctx", ctx -> null)); + assertFalse(queue.tryPut("one", "two", (first, second) -> null)); + assertEquals(0, queue.size()); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i), "every declined place must have been given back"); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aNullProducerThrowsAndGivesBackTheClaimedPlace( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + assertThrows(NullPointerException.class, () -> queue.tryPut("ctx", null)); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i), "the place claimed before the call must not be stranded"); + } + } + + /** A context is the caller's own value; the queue carries it and never looks at it. */ + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aNullContextIsCarriedThroughToTheProducer( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + assertTrue(queue.tryPut((String) null, ctx -> ctx == null ? "absent" : "present")); + assertTrue(queue.tryPut(null, null, (first, second) -> first == null ? "both" : "neither")); + assertEquals( + 1, + queue.tryPutBatch( + Arrays.asList(1), null, (source, context) -> context == null ? "null ctx" : "ctx")); + assertEquals(Arrays.asList("absent", "both", "null ctx"), consumeAll(queue)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aNullRejectHandlerSaysWhatOmittingItSays( + String name, IntFunction> factory) { + WorkQueue withNull = factory.apply(CAPACITY); + int admittedWithNull = + withNull.tryPutBatch( + Arrays.asList(1, 2, 3, 4, 5, 6), "x", (source, suffix) -> source + suffix, null); + WorkQueue without = factory.apply(CAPACITY); + int admittedWithout = + without.tryPutBatch( + Arrays.asList(1, 2, 3, 4, 5, 6), "x", (source, suffix) -> source + suffix); + assertEquals(CAPACITY, admittedWithNull); + assertEquals(admittedWithout, admittedWithNull); + assertEquals(consumeAll(without), consumeAll(withNull)); + } + + // --- Retry is a step, not an outcome. --- + + /** + * A retry has to claim a place like any other admission, so a queue that refilled behind the + * failed item refuses it. This is the one loss with no channel back to the caller: {@code + * processOrRetry} returns only whether there was an item, so the strategy's own return is the + * only report that the item was given up on, and nothing here reads it. + */ + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aRefusedRetryIsReportedToTheStrategyWhenTheQueueRefilled( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i)); + } + AtomicBoolean retryRefused = new AtomicBoolean(); + assertTrue( + queue.processOrRetry( + item -> { + throw new IllegalStateException("consumer failed on " + item); + }, + (item, attempt, failure, retryQueue) -> { + // Take the place the failed item vacated, so the retry has nowhere to land. + assertTrue(queue.tryPut("filler")); + retryRefused.set(!retryQueue.retry(item)); + return false; + })); + assertTrue(retryRefused.get(), "the queue was full again, so the retry had to be refused"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aSuccessfulRetryTakesAPlaceAgain(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i)); + } + AtomicInteger seenAttempt = new AtomicInteger(); + assertTrue( + queue.processOrRetry( + item -> { + throw new IllegalStateException("consumer failed on " + item); + }, + (item, attempt, failure, retryQueue) -> { + seenAttempt.set(attempt); + return retryQueue.retry(item); + })); + assertEquals(1, seenAttempt.get(), "the first failure reports attempt 1"); + assertEquals(CAPACITY, queue.size(), "the retried item took a place again"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aBatchLongerThanOneClaimKeepsClaiming(String name, IntFunction> factory) { + // Deliberately more than the per-claim cap, so the batch cannot be served by one claim. A + // short grant is not the end of the batch, and this is what says so. A power of two, because + // the MPSC backing takes the bound from the ring it rounded up to. + int size = 64; + WorkQueue queue = factory.apply(size); + List elements = new ArrayList<>(); + for (int i = 0; i < size; i++) { + elements.add("e" + i); + } + assertTrue(queue.tryPutBatch(elements).isEmpty(), "there was room for all of them"); + assertEquals(size, queue.size()); + assertEquals(elements, consumeAll(queue)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aBatchLongerThanOneClaimStopsExactlyAtCapacity( + String name, IntFunction> factory) { + int size = 64; + WorkQueue queue = factory.apply(size); + List elements = new ArrayList<>(); + for (int i = 0; i < size + 10; i++) { + elements.add("e" + i); + } + Collection rejected = queue.tryPutBatch(elements); + assertEquals(10, rejected.size(), "short by exactly the overflow, not by a whole claim"); + assertEquals(elements.subList(size, size + 10), new ArrayList<>(rejected)); + assertEquals(size, queue.size()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aBatchClaimsNothingOnceClosed(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.close(); + List elements = Arrays.asList("a", "b", "c"); + assertEquals(elements, new ArrayList<>(queue.tryPutBatch(elements))); + assertEquals(0, queue.size(), "a closed queue took nothing"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void batchesAndSingleAdmissionsRacingCannotBetweenThemPassTheBound( + String name, IntFunction> factory) throws InterruptedException { + // A batch claim spends several places with one add and gives back what it could not use. If + // the giving back were wrong in either direction the bound would move: too little back and the + // queue silently shrinks, too much and it overfills. Racing the two shapes against each other + // is what makes an arithmetic slip visible. + int capacity = 64; + int threads = 8; + WorkQueue queue = factory.apply(capacity); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger admitted = new AtomicInteger(); + List racers = new ArrayList<>(); + for (int t = 0; t < threads; t++) { + boolean batching = t % 2 == 0; + Thread racer = + new Thread( + () -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + if (batching) { + List batch = Arrays.asList("a", "b", "c", "d", "e", "f", "g", "h"); + admitted.addAndGet(batch.size() - queue.tryPutBatch(batch).size()); + } else { + for (int i = 0; i < 8; i++) { + if (queue.tryPut("s" + i)) { + admitted.incrementAndGet(); + } + } + } + }); + racers.add(racer); + racer.start(); + } + start.countDown(); + for (Thread racer : racers) { + racer.join(SECONDS.toMillis(10)); + assertFalse(racer.isAlive(), "racer did not finish"); + } + assertEquals(admitted.get(), queue.size(), "every admission that reported took a place"); + assertTrue(admitted.get() <= capacity, "the bound held: " + admitted.get() + " > " + capacity); + // Whatever was refused, the places are all accounted for: draining gives back exactly what + // went in, and the queue then takes a full capacity again. + assertEquals(admitted.get(), consumeAll(queue).size()); + for (int i = 0; i < capacity; i++) { + assertTrue(queue.tryPut("after" + i), "place " + i + " was lost"); + } + assertFalse(queue.tryPut("overflow")); + } + + // --- The contextual consumers, and the release paths the batch admission depends on. --- + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aContextualConsumerSeesTheContextAndEachForm( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + List seen = new ArrayList<>(); + + queue.tryPut("a"); + assertTrue(queue.process("ctx", (context, item) -> seen.add(context + ":" + item))); + + queue.tryPut("b"); + assertTrue( + queue.processOrHandle( + "ctx", + (context, item) -> { + throw new IllegalStateException(context + ":" + item); + }, + (item, failure) -> seen.add("handled " + failure.getMessage()))); + + queue.tryPut("c"); + AtomicInteger attempts = new AtomicInteger(); + while (queue.processOrRetry( + "ctx", + (context, item) -> { + if (attempts.incrementAndGet() == 1) { + throw new IllegalStateException("first attempt"); + } + seen.add(context + ":" + item); + }, + maxRetries(1))) { + // drain, including the retry + } + + assertEquals(Arrays.asList("ctx:a", "handled ctx:b", "ctx:c"), seen); + assertEquals(2, attempts.get(), "the failed attempt was retried once"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void everyConsumerFormReportsAnEmptyQueue(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + assertFalse(queue.processOrHandle(item -> fail("no item"), (item, failure) -> fail("no item"))); + assertFalse(queue.process("ctx", (context, item) -> fail("no item"))); + assertFalse(queue.processOrRetry("ctx", (context, item) -> fail("no item"), maxRetries(1))); + assertFalse( + queue.processOrHandle( + "ctx", (context, item) -> fail("no item"), (item, failure) -> fail("no item"))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aThrowingTwoContextProducerGivesBackItsPlace( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + assertThrows( + IllegalStateException.class, + () -> + queue.tryPut( + "one", + "two", + (first, second) -> { + throw new IllegalStateException("boom"); + })); + assertEquals(0, queue.size()); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i), "the throw must not have cost the queue a place"); + } + } + + /** The array form of {@link #aNullElementThrowsOutOfABatchAndAbandonsTheRest}. */ + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aNullElementThrowsOutOfAnArrayBatchAndAbandonsTheRest( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + assertThrows(NullPointerException.class, () -> queue.tryPutBatch("a", null, "b")); + assertEquals(Arrays.asList("a"), consumeAll(queue), "what came before the null is admitted"); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i), "the throw must not have cost the queue a place"); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aThrowingProducerInABatchGivesBackTheRunsPlaces( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + assertThrows( + IllegalStateException.class, + () -> + queue.tryPutBatch( + Arrays.asList(1, 2, 3), + "x", + (source, suffix) -> { + if (source == 2) { + throw new IllegalStateException("boom"); + } + return source + suffix; + })); + assertEquals(Arrays.asList("1x"), consumeAll(queue), "what came before the throw is admitted"); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i), "the throw must not have cost the queue a place"); + } + } + + /** + * A batch claims places by {@code size()} before it walks the iterator, so a collection that + * reports more than it yields -- a lying {@code size()}, or one changing underneath -- must not + * keep the difference. + */ + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aCollectionThatOverstatesItsSizeCostsNoPlaces( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + assertTrue(queue.tryPutBatch(new OverstatedSize<>("a")).isEmpty()); + assertEquals( + 1, queue.tryPutBatch(new OverstatedSize<>(1), "x", (source, suffix) -> source + suffix)); + assertEquals(Arrays.asList("a", "1x"), consumeAll(queue)); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i), "place " + i + " was kept for an element that never came"); + } + } + + /** Holds the given elements but reports a few more, the way a racing size() can. */ + private static final class OverstatedSize extends ArrayList { + @SafeVarargs + OverstatedSize(E... elements) { + super(Arrays.asList(elements)); + } + + @Override + public int size() { + return super.size() + 2; + } + } + + /** + * A backing that claims to be unable to store anything, so the one outcome no real backing + * produces -- a refusal of an element a place was already claimed for -- can be tested at all. + */ + private static final class RefusingWorkQueue extends BaseWorkQueue { + RefusingWorkQueue(int capacity) { + super(capacity); + } + + @Override + boolean store(Object element) { + return false; + } + + @Override + Object retrieve() { + return null; + } + } + + @Test + void aRefusedFillGivesThePlaceBack() { + RefusingWorkQueue queue = new RefusingWorkQueue<>(1); + try (Reservation place = queue.tryReserve()) { + assertTrue(place.granted()); + place.fill("lost"); + } + assertEquals(0, queue.size(), "the place was not given back"); + // And the capacity is still usable, which is the part a leaked place would break. + assertTrue(queue.tryReserve().granted(), "the place was lost for good"); + } + + @Test + void aRefusedStoreOnAPlainPutIsReportedAndLeaksNoPlace() { + RefusingWorkQueue queue = new RefusingWorkQueue<>(1); + assertFalse(queue.tryPut("lost")); + assertEquals(0, queue.size()); + // And the place came back, which is the part a leak would break. + assertTrue(queue.tryReserve().granted(), "the place was lost for good"); + } + + @Test + void aRefusedStoreInABatchIsReturnedAndLeaksNoPlace() { + RefusingWorkQueue queue = new RefusingWorkQueue<>(2); + assertEquals(Arrays.asList("a", "b"), queue.tryPutBatch("a", "b")); + assertEquals(Arrays.asList("c", "d"), queue.tryPutBatch(Arrays.asList("c", "d"))); + assertEquals(0, queue.size(), "a refused element must give its place back"); + assertTrue(queue.tryReserve().granted(), "the places were lost for good"); + } + + @Test + void aRefusedStoreInAProducerBatchIsRejectedAndLeaksNoPlace() { + RefusingWorkQueue queue = new RefusingWorkQueue<>(1); + List rejected = new ArrayList<>(); + assertEquals( + 0, + queue.tryPutBatch( + Arrays.asList(1), "x", (source, suffix) -> source + suffix, rejected::add)); + assertEquals(Arrays.asList(1), rejected, "the refusal reaches the reject handler"); + assertEquals(0, queue.size()); + assertTrue(queue.tryReserve().granted(), "the place was lost for good"); + } + + private static List consumeAll(WorkQueue queue) { + List consumed = new ArrayList<>(); + while (queue.process(consumed::add)) { + // drain + } + return consumed; + } +}