diff --git a/utils/queue-utils/build.gradle.kts b/utils/queue-utils/build.gradle.kts index 6b72ab9e6a0..2184464b06c 100644 --- a/utils/queue-utils/build.gradle.kts +++ b/utils/queue-utils/build.gradle.kts @@ -4,6 +4,7 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion plugins { `java-library` id("dd-trace-java.module.internal-library") + id("dd-trace-java.jmh-conventions") } dependencies { diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionAlternativesBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionAlternativesBenchmark.java new file mode 100644 index 00000000000..b899d95bda9 --- /dev/null +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionAlternativesBenchmark.java @@ -0,0 +1,259 @@ +package datadog.common.queue; + +import java.util.Queue; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.jctools.queues.MpscArrayQueue; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * {@link WorkQueue} against the things a caller would otherwise use, including the two it is + * actually replacing. Published because the comparison exists whether or not we run it, and a + * reader who has to measure it themselves is entitled to wonder what we found. + * + *
+ * ./gradlew :utils:queue-utils:jmh -Pjmh.includes=AdmissionAlternatives -Pjmh.profilers=gc
+ * 
+ * + *

The decision this table is for. Use {@link MpscArrayQueue} directly when one consumer, + * the ring's own bound, and an element you already hold are the whole requirement -- it is the + * floor here and nothing built on top of it will beat it. Reach for {@link WorkQueue} when the + * element costs something to build, when a drop needs counting, when a failed consumer needs a + * retry or a handler, or when the queue has a lifecycle. That is a real choice with a real answer + * on both sides, and the numbers below are what it costs either way. + * + *

The baselines are not inventions. {@code arrayBlocking} is {@code WafMetricCollector}, + * which offers into an {@code ArrayBlockingQueue(1024)}; {@code linkedBlocking} is {@code + * RumInjectorMetrics}, which offers into a {@code LinkedBlockingQueue(1024)} and drops the return + * value. Both build their element first and find out afterwards whether there was room. {@code + * clqWithCounter} is the other thing people write: a {@link ConcurrentLinkedQueue} with an {@link + * AtomicInteger} in front of it, guarded by a read and then incremented -- check-then-act, so two + * threads at the boundary can both pass, and the bound is a suggestion. It is here because it is + * common, not because it is correct; that it is racy is part of what is being compared. + * + *

Two halves, because the answer differs. The {@code steady} arms admit and drain at one + * thread: the per-operation cost with nothing else happening, which is where the alternatives look + * their best. The {@code refused} arms sit on a full queue at four threads: the boundary, where a + * bounded queue spends its time under load, and where building an element before asking is a wasted + * allocation on every call. Read both. A caller whose queue is never full lives in the first table + * and should weigh the API for what it buys, not for its speed. + * + *

Results. JDK 17, one machine, {@code -Pjmh.forks=1}; the four-thread arms carry wide error + * bars and the ranking within the incumbents is not meaningful, but the separation from {@code + * refusedWorkQueue} is an order of magnitude and survives any reading of them. + * + *

+ * Benchmark                 threads    ns/op   B/op
+ * steadyRawMpsc                   1     24.2     24
+ * steadyWorkQueue                 1     36.3     24
+ * steadyArrayBlocking             1     38.8     24
+ * steadyClqWithCounter            1     42.8     48
+ * steadyLinkedBlocking            1     46.2     48
+ * refusedWorkQueue                4      7.2      0
+ * refusedClqWithCounter           4    146.1     24
+ * refusedLinkedBlocking           4    148.0     24
+ * refusedRawMpsc                  4    160.2     24
+ * refusedArrayBlocking            4    180.5     24
+ * 
+ * + *

What the two halves say. Admitting, the raw ring is the floor at 24ns and nothing here + * reaches it; {@link WorkQueue} costs 12ns more for the counted drop, the lifecycle, and the + * producer callback. Both incumbents cost more than that, and the two linked queues allocate a node + * per element on top of the element itself. So the API is not the expensive option even in the case + * that flatters the alternatives. + * + *

Refusing, the separation is not subtle, and it is not really about the queue. {@code + * refusedWorkQueue} does no allocation at all, because the place is claimed before the producer is + * ever called and there was no place; every other arm has already built its element by the time it + * asks. That is the designed difference rather than an artifact of the harness -- but read it as + * such. A caller whose element is a preexisting object, or is free to build, keeps the shape of + * this gap and not its size. + * + *

The honest caveat. {@code refusedRawMpsc} is in the same band as the incumbents, which + * is the reminder that the floor is a floor for admitting, not for refusing: a full ring still + * touches a line the consumer is moving. {@code refusedWorkQueue} is fast because refusal is a load + * against a counter no refusing thread writes -- see {@code ContendedAdmissionBenchmark}, which is + * where that came from and what it cost before. + */ +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@State(Scope.Benchmark) +public class AdmissionAlternativesBenchmark { + + private static final int CAPACITY = 1024; + + private static final String ELEMENT = "element"; + + /** + * What the incumbents build before they ask. Stands in for a {@code WafMetric} or a metric + * sample: not exact, but a real allocation with a stable footprint, so the arm that builds one + * can be told from the arm that does not. + */ + static final class Payload { + final Object a; + final long timestamp; + + Payload(Object a, long timestamp) { + this.a = a; + this.timestamp = timestamp; + } + } + + /** Only ever called once a place is already claimed, which is the whole difference. */ + private static final Producer BUILDER = () -> new Payload(ELEMENT, System.nanoTime()); + + private MpscArrayQueue steadyRaw; + private BlockingQueue steadyArrayBlocking; + private BlockingQueue steadyLinkedBlocking; + private Queue steadyClq; + private AtomicInteger steadyClqCount; + private WorkQueue steadyWork; + + private MpscArrayQueue fullRaw; + private BlockingQueue fullArrayBlocking; + private BlockingQueue fullLinkedBlocking; + private Queue fullClq; + private AtomicInteger fullClqCount; + private WorkQueue fullWork; + + @Setup + public void setUp() { + steadyRaw = new MpscArrayQueue<>(CAPACITY); + steadyArrayBlocking = new ArrayBlockingQueue<>(CAPACITY); + steadyLinkedBlocking = new LinkedBlockingQueue<>(CAPACITY); + steadyClq = new ConcurrentLinkedQueue<>(); + steadyClqCount = new AtomicInteger(); + steadyWork = WorkQueues.createMpscQueue(CAPACITY); + + fullRaw = new MpscArrayQueue<>(16); + fullArrayBlocking = new ArrayBlockingQueue<>(16); + fullLinkedBlocking = new LinkedBlockingQueue<>(16); + fullClq = new ConcurrentLinkedQueue<>(); + fullClqCount = new AtomicInteger(); + fullWork = WorkQueues.createMpscQueue(16); + for (int i = 0; i < 16; i++) { + Payload payload = new Payload(ELEMENT, i); + fullRaw.offer(payload); + fullArrayBlocking.offer(payload); + fullLinkedBlocking.offer(payload); + fullClq.offer(payload); + fullClqCount.incrementAndGet(); + fullWork.tryPut(payload); + } + } + + // --------------------------------------------------------------------------------------------- + // Steady: admit one, drain one, at a single thread. Every alternative at its best. + // --------------------------------------------------------------------------------------------- + + @Benchmark + @Threads(1) + public void steadyRawMpsc(Blackhole bh) { + bh.consume(steadyRaw.offer(new Payload(ELEMENT, System.nanoTime()))); + bh.consume(steadyRaw.poll()); + } + + /** {@code WafMetricCollector}: build, then offer, then find out. */ + @Benchmark + @Threads(1) + public void steadyArrayBlocking(Blackhole bh) { + bh.consume(steadyArrayBlocking.offer(new Payload(ELEMENT, System.nanoTime()))); + bh.consume(steadyArrayBlocking.poll()); + } + + /** {@code RumInjectorMetrics}: the same, over a linked queue. */ + @Benchmark + @Threads(1) + public void steadyLinkedBlocking(Blackhole bh) { + bh.consume(steadyLinkedBlocking.offer(new Payload(ELEMENT, System.nanoTime()))); + bh.consume(steadyLinkedBlocking.poll()); + } + + /** The hand-rolled bound: a read, then an increment, with a window between them. */ + @Benchmark + @Threads(1) + public void steadyClqWithCounter(Blackhole bh) { + if (steadyClqCount.get() < CAPACITY) { + steadyClqCount.incrementAndGet(); + bh.consume(steadyClq.offer(new Payload(ELEMENT, System.nanoTime()))); + } + if (steadyClq.poll() != null) { + steadyClqCount.decrementAndGet(); + } + } + + @Benchmark + @Threads(1) + public void steadyWorkQueue(Blackhole bh) { + bh.consume(steadyWork.tryPut(BUILDER)); + steadyWork.process(bh::consume); + } + + // --------------------------------------------------------------------------------------------- + // Refused: a full queue, four threads. The boundary, where a bounded queue lives under load. + // --------------------------------------------------------------------------------------------- + + @Benchmark + @Threads(4) + public void refusedRawMpsc(Blackhole bh) { + Payload payload = new Payload(ELEMENT, System.nanoTime()); + bh.consume(fullRaw.offer(payload)); + bh.consume(payload); + } + + @Benchmark + @Threads(4) + public void refusedArrayBlocking(Blackhole bh) { + Payload payload = new Payload(ELEMENT, System.nanoTime()); + bh.consume(fullArrayBlocking.offer(payload)); + bh.consume(payload); + } + + @Benchmark + @Threads(4) + public void refusedLinkedBlocking(Blackhole bh) { + Payload payload = new Payload(ELEMENT, System.nanoTime()); + bh.consume(fullLinkedBlocking.offer(payload)); + bh.consume(payload); + } + + /** + * The one arm where the hand-rolled guard is doing what it was written for: refusing without + * touching the queue. It still builds the element first, because the caller had no way to know. + */ + @Benchmark + @Threads(4) + public void refusedClqWithCounter(Blackhole bh) { + Payload payload = new Payload(ELEMENT, System.nanoTime()); + if (fullClqCount.get() < 16) { + fullClqCount.incrementAndGet(); + bh.consume(fullClq.offer(payload)); + } + bh.consume(payload); + } + + /** Never asked to build, because the place is claimed first and there was none. */ + @Benchmark + @Threads(4) + public void refusedWorkQueue(Blackhole bh) { + bh.consume(fullWork.tryPut(BUILDER)); + } +} diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionBenchmark.java new file mode 100644 index 00000000000..8dd833bdd9a --- /dev/null +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionBenchmark.java @@ -0,0 +1,228 @@ +package datadog.common.queue; + +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * The three admission forms measured against each other on a real queue, to answer one question: + * does the reservation object survive escape analysis? If it does, the reserve route costs what the + * producer routes cost and {@link BiContextualProducer} was never needed for client-side stats. If + * it does not, the producer callbacks are earning their contortions. + * + *

./gradlew :utils:queue-utils:jmh -Pjmh.includes=Admission -Pjmh.profilers=gc
+ * + *

{@code gc.alloc.rate.norm} is the number that answers it; the timings are secondary and are + * muddied on purpose, because every arm consumes the item it just admitted to keep the queue at + * steady state. The element itself is preallocated in every arm, including the producer ones, so + * what is being compared is the admission machinery and not the cost of building an element. + * + *

The {@code backings} parameter is the template-method question. {@code store} and {@code + * retrieve} are one call site each, shared by every backing in the process, so their receiver + * profile is global: {@code ONE} loads a single concrete subclass and C2 inlines through them, + * {@code BOTH} loads two and it still does behind a type guard, {@code THREE} is the cliff. Every + * arm drives two extra queues through the same sites for the same number of iterations, so the + * traffic is identical and only the number of distinct types in it changes. + * + *

Results: + * + *

+ * Benchmark             ONE     BOTH    THREE    THREE-ONE
+ * tryPutElement        21.19   20.88   22.44      +1.3
+ * tryPutProducer       20.82   20.89   21.81      +1.0
+ * tryPutContextual     20.83   20.86   22.85      +2.0
+ * tryPutBiContextual   21.25   21.29   21.83      +0.6
+ * reserveAndFill       21.04   22.92   27.61      +6.6
+ * reserveMixed         12.69   12.75   13.71      +1.0
+ * reserveRefused        8.45    7.25    7.16       0
+ * 
+ * + *

JDK 25, {@code -Pjmh.forks=2}, ns/op. Every arm allocates 0.01 B/op or less, {@code THREE} + * included. Error bars are within ±0.6 except {@code reserveAndFill} at {@code BOTH} (±4.3) and + * {@code THREE} (±2.3), and {@code reserveRefused} at {@code ONE} (±1.9). + * + *

Two things to read off it. A second backing is free — every arm is flat from {@code ONE} to + * {@code BOTH}. A third is not free but is small: one to two nanoseconds on a twenty-one nanosecond + * operation, because an admit-and-drain pays two uncontended atomics and a ring compare-and-set, + * and an out-of-line call is little against memory ordering. {@code reserveRefused} is the control: + * it never reaches {@code store}, and it does not move. + * + *

{@code reserveAndFill} is the exception worth knowing about, at roughly 30%. Its {@code store} + * happens inside {@link Reservation#fill} on an object that only exists if escape analysis deletes + * it, so that arm is not paying for a virtual call so much as for a longer chain of optimizations + * having to survive one. The allocation still goes away — the reservation is still scalar-replaced + * at {@code THREE} — but the call it wraps no longer folds into the caller. A caller admitting + * through a reservation is the one with something to lose from a third backing. + * + *

The refusal-design figure that {@link BaseWorkQueue} cites lives here too, and is unchanged by + * any of the above: {@code reserveMixed} measured 12 B/op when a refused reservation was a shared + * singleton and 0 B/op when it is its own allocation, on JDK 17. Only that arm can tell the two + * designs apart — {@code reserveAndFill} and {@code reserveRefused} each see a single outcome, so + * C2 prunes the branch that never runs and there is no merge left to defeat escape analysis. + * + *

{@link ThirdBackingWorkQueue} is the third type, and lives in this source set rather than in + * the module: the question is what a third backing would cost, and shipping one to find out + * would answer a different question. + */ +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@Threads(1) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@State(Scope.Thread) +public class AdmissionBenchmark { + + public enum Backings { + ONE, + BOTH, + THREE + } + + private static final String ELEMENT = "element"; + + private static final Producer PRODUCER = () -> ELEMENT; + + private static final ContextualProducer CONTEXTUAL = context -> context; + + private static final BiContextualProducer BI_CONTEXTUAL = + (first, second) -> first; + + @Param({"ONE", "BOTH", "THREE"}) + public Backings backings; + + /** Alternates the reserving queue in {@link #reserveMixed}, so one site sees both outcomes. */ + private int mixer; + + /** The queue under test. */ + private WorkQueue queue; + + /** Kept full for the whole run, so its reservations are always refused. */ + private WorkQueue full; + + /** + * Two more queues driven through the same call sites as the queue under test, and the whole of + * what the parameter varies. Every arm allocates both and drives both, so the traffic arriving at + * {@code store} and {@code retrieve} is identical; only the number of distinct types in it + * changes. An arm that skipped the loop would also differ in how hard its call sites had been + * exercised before measurement, which is not the question being asked. + */ + private WorkQueue second; + + private WorkQueue third; + + @Setup + public void setUp() { + queue = WorkQueues.createMpscQueue(1024); + full = WorkQueues.createMpscQueue(1); + full.tryPut(ELEMENT); + second = + backings == Backings.ONE + ? WorkQueues.createMpscQueue(1024) + : WorkQueues.createMpmcQueue(1024); + third = + backings == Backings.THREE + ? new ThirdBackingWorkQueue<>(1024) + : WorkQueues.createMpscQueue(1024); + } + + /** + * Runs the other backings through the same methods, so every loaded type reaches the shared call + * sites. Repeated before every iteration rather than once per trial: the measured loop drives one + * type only, and a profile that saw the others just once at startup is not the profile a process + * with several live backings actually has. + */ + @Setup(Level.Iteration) + public void pollute(Blackhole bh) { + for (int i = 0; i < 20_000; i++) { + second.tryPut(ELEMENT); + second.process(bh::consume); + third.tryPut(ELEMENT); + third.process(bh::consume); + } + } + + @Benchmark + public void tryPutElement(Blackhole bh) { + bh.consume(queue.tryPut(ELEMENT)); + queue.process(bh::consume); + } + + @Benchmark + public void tryPutProducer(Blackhole bh) { + bh.consume(queue.tryPut(PRODUCER)); + queue.process(bh::consume); + } + + @Benchmark + public void tryPutContextual(Blackhole bh) { + bh.consume(queue.tryPut(ELEMENT, CONTEXTUAL)); + queue.process(bh::consume); + } + + @Benchmark + public void tryPutBiContextual(Blackhole bh) { + bh.consume(queue.tryPut(ELEMENT, ELEMENT, BI_CONTEXTUAL)); + queue.process(bh::consume); + } + + @Benchmark + public void reserveAndFill(Blackhole bh) { + Reservation place = queue.tryReserve(); + try { + if (place.granted()) { + place.fill(ELEMENT); + } + } finally { + place.close(); + } + queue.process(bh::consume); + } + + /** The refusal path, which is where the shared singleton was supposed to be paying off. */ + @Benchmark + public void reserveRefused(Blackhole bh) { + Reservation place = full.tryReserve(); + try { + bh.consume(place.granted()); + } finally { + place.close(); + } + } + + /** + * Both outcomes through one call site, which is the only shape where how a refusal is represented + * can cost anything. + * + *

The two arms above each see a single outcome, so C2 prunes the branch that never runs and + * there is no merge to defeat escape analysis — they read zero whether a refusal is a shared + * singleton or its own allocation, and neither one can tell the two designs apart. A caller whose + * queue is nearly always accepting is genuinely in that case. A caller that sits at the boundary, + * refusing about as often as it admits, is in this one. + */ + @Benchmark + public void reserveMixed(Blackhole bh) { + WorkQueue target = (mixer++ & 1) == 0 ? queue : full; + Reservation place = target.tryReserve(); + try { + if (place.granted()) { + place.fill(ELEMENT); + } + } finally { + place.close(); + } + queue.process(bh::consume); + } +} diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/BackingOverheadBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/BackingOverheadBenchmark.java new file mode 100644 index 00000000000..ebc36e7d148 --- /dev/null +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/BackingOverheadBenchmark.java @@ -0,0 +1,99 @@ +package datadog.common.queue; + +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; +import org.jctools.queues.MpmcArrayQueue; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * The two structures the multi-consumer backing could sit on, priced against each other. This is + * the measurement {@link MpmcWorkQueue} chose the array ring on. + * + *

./gradlew :utils:queue-utils:jmh -Pjmh.includes=BackingOverhead -Pjmh.profilers=gc
+ * + *

The element is a preallocated singleton in every arm, so the only allocation measured is the + * structure's own — which is the point, because the linked queue builds a node per element and the + * ring builds nothing. + * + *

+ * Benchmark          threads    ns/op   B/op
+ * mpmcSteady               1     12.7      0
+ * clqSteady                1     20.6     24
+ * mpmcContended            4    559       0
+ * clqContended             4    625      24
+ * 
+ * + *

Uncontended the ring is about 38% cheaper and stops manufacturing 24 bytes of garbage per + * element, which at a million elements a second is 24MB/s the linked queue creates and the ring + * does not. The four-thread arms are ±287 and ±540 respectively and say nothing: each thread offers + * and polls the same queue, so both ends of it are thrashed and the measurement is of the harness. + * They are kept because leaving them out would imply the contended case was measured and settled. + * + *

What the ring costs in exchange is that it is not linearizable — see {@link MpmcWorkQueue} for + * why claiming a place first makes that survivable, and {@link Queues#mpmcArrayQueue} for why it + * would not be otherwise. + */ +@Fork(1) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@State(Scope.Benchmark) +public class BackingOverheadBenchmark { + + private static final int CAPACITY = 1024; + private static final String ELEMENT = "element"; + + private Queue clq1; + private Queue mpmc1; + private Queue clqN; + private Queue mpmcN; + + @Setup + public void setUp() { + clq1 = new ConcurrentLinkedQueue<>(); + mpmc1 = new MpmcArrayQueue<>(CAPACITY); + clqN = new ConcurrentLinkedQueue<>(); + mpmcN = new MpmcArrayQueue<>(CAPACITY); + } + + @Benchmark + @Threads(1) + public void clqSteady(Blackhole bh) { + bh.consume(clq1.offer(ELEMENT)); + bh.consume(clq1.poll()); + } + + @Benchmark + @Threads(1) + public void mpmcSteady(Blackhole bh) { + bh.consume(mpmc1.offer(ELEMENT)); + bh.consume(mpmc1.poll()); + } + + @Benchmark + @Threads(4) + public void clqContended(Blackhole bh) { + bh.consume(clqN.offer(ELEMENT)); + bh.consume(clqN.poll()); + } + + @Benchmark + @Threads(4) + public void mpmcContended(Blackhole bh) { + bh.consume(mpmcN.offer(ELEMENT)); + bh.consume(mpmcN.poll()); + } +} diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/BatchAdmissionBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/BatchAdmissionBenchmark.java new file mode 100644 index 00000000000..03d8d796933 --- /dev/null +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/BatchAdmissionBenchmark.java @@ -0,0 +1,177 @@ +package datadog.common.queue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * What one claim for a run of elements saves over one claim per element, with nothing else going + * on. The contended half of the question -- what the same batch costs the producers next to it -- + * is {@link ContendedBatchAdmissionBenchmark}; this one is the ceiling on the saving. + * + *

+ * ./gradlew :utils:queue-utils:jmh -Pjmh.includes=BatchAdmission -Pjmh.profilers=gc
+ * 
+ * + *

Each arm admits {@code batchSize} elements and drains the same number, so the queue sits well + * off its bound and every admission succeeds. Timings are per call, which is per batch: divide by + * {@code batchSize} for the per-element figure the pair is actually comparing. The elements are + * built once in setup, so what is measured is admission and not construction. + * + *

What the saving can be. Admission costs a claim on the shared counter and a store into + * the backing. Batching removes all but one of the claims and none of the stores, and the MPSC + * ring's store is itself a compare-and-set on the producer index. So the ceiling here is a little + * under half the per-element cost on that backing, and rather more on the linked one, where the + * store is a node allocation and a CAS on the tail but the counter is the only bound there is. + * + *

Why the batch arm cannot simply win. It walks an iterator and refills a claim every + * {@code MAX_BATCH_CLAIM} elements, which the loop arm does not. The sizes run from 4 to 128 to + * find where that bookkeeping stops costing more than the claims it removes -- and the answer, + * below, is that at one thread it never does. + * + *

Results, and they go the other way. One fork, five iterations, JDK 25, on a machine + * with other work on it -- but the intervals here are tight (a percent or two), because there is no + * contention to amplify anything. The per-element columns are the call divided by {@code + * batchSize}. + * + *

+ * Benchmark          (backings)  (batchSize)     ns/op   ns/element
+ * batchOfElements    MPSC        4                68.4         17.1
+ * loopOfElements     MPSC        4                58.1         14.5
+ * batchOfElements    MPSC        8               137.0         17.1
+ * loopOfElements     MPSC        8               114.9         14.4
+ * batchOfElements    MPSC        32              519.6         16.2
+ * loopOfElements     MPSC        32              456.7         14.3
+ * batchOfElements    MPSC        128            2084.0         16.3
+ * loopOfElements     MPSC        128            1825.2         14.3
+ * batchOfElements    MPMC        4                84.4         21.1
+ * loopOfElements     MPMC        4                83.7         20.9
+ * batchOfElements    MPMC        8               169.8         21.2
+ * loopOfElements     MPMC        8               170.2         21.3
+ * batchOfElements    MPMC        32              700.1         21.9
+ * loopOfElements     MPMC        32              692.4         21.6
+ * batchOfElements    MPMC        128            2785.1         21.8
+ * loopOfElements     MPMC        128            2804.0         21.9
+ * 
+ * + *

Every arm allocates nothing measurable now that the multi-consumer backing is array-backed + * rather than linked. The MPMC rows above were within a nanosecond per element of these on the + * linked queue, which manufactured a node per element to get there -- 24 bytes an element, priced + * in {@link BackingOverheadBenchmark}. Time at parity, allocation gone, is the whole of what that + * swap bought; the 8ns per operation the structures differ by in isolation does not survive the + * admission machinery being laid on top of it. + * + *

The producer arms track the element arms to within a nanosecond per element at every size, on + * both backings, so they are left out of the table above; the claim is what they share and the + * producer call is inlined away. + * + *

Batching is slower here, by about 2ns per element on MPSC and not at all on MPMC. That + * is the honest ceiling on the saving, and it is negative: an uncontended atomic add is a few + * cycles, and removing 31 of them out of 32 does not pay for walking an iterator and refilling a + * claim. Some of that gap is the iterator itself rather than the claim bookkeeping -- the loop arm + * indexes an {@code ArrayList} and the batch arm cannot, because its argument is a {@code + * Collection}. It does not much matter which half it is: the size is the same either way and the + * sign does not change. + * + *

Nor does the sign change with batch size, which is the useful part. The saving does not turn + * positive at 128, so there is no crossover to find and no minimum batch size to recommend on this + * evidence. Whatever batching is worth, it is not worth anything to a producer that is alone. + * + *

Which is the point. The claim's cost is not in the instruction, it is in the line + * everyone else is reading -- and a benchmark with one thread has taken that out. Read this table + * as the floor and {@link ContendedBatchAdmissionBenchmark} as the case: there the same batching + * producer is between 1.3x and 3.3x faster than the same loop. A caller choosing between them + * should be asking how many threads share the queue, not how long its batches are. + */ +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@Threads(1) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@State(Scope.Thread) +public class BatchAdmissionBenchmark { + + public enum Backings { + MPSC, + MPMC + } + + /** Comfortably above the largest batch, so no arm is admitting at the bound. */ + private static final int CAPACITY = 1024; + + private static final String ELEMENT = "element"; + + /** + * Turns a source element into the queue's element without allocating, as a real one would not. + */ + private static final BiContextualProducer PRODUCER = + (source, context) -> source; + + @Param({"MPSC", "MPMC"}) + public Backings backings; + + @Param({"4", "8", "32", "128"}) + public int batchSize; + + private WorkQueue queue; + + /** Built once: the arms compare admission, not the cost of producing a list. */ + private List elements; + + @Setup + public void setUp() { + queue = + backings == Backings.MPSC + ? WorkQueues.createMpscQueue(CAPACITY) + : WorkQueues.createMpmcQueue(CAPACITY); + elements = new ArrayList<>(batchSize); + for (int i = 0; i < batchSize; i++) { + elements.add(ELEMENT); + } + } + + /** One claim per run of elements. */ + @Benchmark + public void batchOfElements(Blackhole bh) { + bh.consume(queue.tryPutBatch(elements)); + bh.consume(queue.process(batchSize, bh::consume)); + } + + /** The same admissions, one claim each: what a caller writes without the batch API. */ + @Benchmark + public void loopOfElements(Blackhole bh) { + for (int i = 0; i < batchSize; i++) { + bh.consume(queue.tryPut(elements.get(i))); + } + bh.consume(queue.process(batchSize, bh::consume)); + } + + /** The producer form, where the claim also gates whether an element is built at all. */ + @Benchmark + public void batchOfProducers(Blackhole bh) { + bh.consume(queue.tryPutBatch(elements, ELEMENT, PRODUCER)); + bh.consume(queue.process(batchSize, bh::consume)); + } + + @Benchmark + public void loopOfProducers(Blackhole bh) { + for (int i = 0; i < batchSize; i++) { + bh.consume(queue.tryPut(elements.get(i), ELEMENT, PRODUCER)); + } + bh.consume(queue.process(batchSize, bh::consume)); + } +} diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedAdmissionBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedAdmissionBenchmark.java new file mode 100644 index 00000000000..25bcbc78883 --- /dev/null +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedAdmissionBenchmark.java @@ -0,0 +1,283 @@ +package datadog.common.queue; + +import static java.util.concurrent.TimeUnit.SECONDS; + +import java.util.concurrent.TimeUnit; +import org.jctools.queues.MpscArrayQueue; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Admission with more than one thread arriving at once, which is the only way two of this module's + * central costs become visible at all. + * + *

+ * ./gradlew :utils:queue-utils:jmh -Pjmh.includes=ContendedAdmission -Pjmh.threads=8 -Pjmh.profilers=gc
+ * 
+ * + *

Allocation, as throughput. A single thread allocates almost for free — a pointer bump + * in a thread-local buffer — so a per-operation allocation shows up in {@code B/op} and barely at + * all in {@code ns/op}, which is how an allocation on a hot path gets waved through. Several + * threads allocating at once pay for it together: buffer refills, the memory bandwidth to touch + * fresh cache lines, and eventually collection. That turns the allocation into a throughput number, + * in the units a reviewer actually argues about. {@link AdmissionBenchmark} measured the + * reservation path at 0 B/op against 12 for a shared refusal singleton, and it measured that at one + * thread — where it is nearly free. This is where 12 B/op gets priced. + * + *

{@code refusedProducer} against {@code refusedBuildThenOffer} is this module's whole premise + * stated as a benchmark. Both sit on a full queue and admit nothing. The first hands over a + * producer and is never asked to build, because a place is claimed first and there was none; the + * second builds its element and then discovers there is no room, which is the shape {@link + * WorkQueue} exists to replace. At one thread the difference is an allocation that escape analysis + * may well erase anyway. Under load it is the difference the API is claiming. + * + *

Contention, as itself. {@link BaseWorkQueue#claimPlace} spends a place with one atomic + * decrement and gives it back with a second when there was none to spend, so a refused admission + * pays two read-modify-writes on one shared line — at the capacity boundary, where the most threads + * are arriving at once. {@code refusedRaw} is the baseline that prices it: on the MPSC backing + * jctools already enforces capacity through its own producer-index CAS, so a caller that never + * reserves is paying the counter for a bound it was getting free, and the delta is what that costs. + * That delta is what the relaxed read in {@code claimPlace} brought down from ~960ns to ~5ns. The + * linked backing has no such baseline — {@code ConcurrentLinkedQueue} is unbounded and the counter + * is the only thing bounding it, so there the comparison is against having no bound at all. + * + *

{@code steady} is the other half, and the commoner one: not full, with a consumer making room + * as fast as producers take it. It is the only arm where the counter is incremented by a drain + * while it is decremented by admission, contending on the same line from both directions. Its + * consumer is a thread this class owns rather than a {@code @Group} member; the arm's own note says + * why that distinction is load-bearing. + * + *

Neither cost is visible in {@link AdmissionBenchmark}, which is {@code @Threads(1)} and {@code + * Scope.Thread} — every thread there gets its own queue, so there is nothing to contend on and + * nothing to allocate alongside. + * + *

Results. Eight threads, one fork, {@code -Pjmh.profilers=gc}, JDK 25, on a machine with other + * work on it -- so the absolute numbers run high and the intervals are wide. They are an + * impression, not a baseline; {@code refusedRaw} is the control on every run. + * + *

The {@code before} column is the decrement-then-back-out admission this benchmark was written + * to price. The {@code after} column is the same run once {@link BaseWorkQueue#claimPlace} took to + * reading the count before spending from it. + * + *

+ * Benchmark                 (backings)   before ns/op   after ns/op   B/op
+ * refusedProducer           MPSC             1035.8           7.3     0
+ * refusedProducer           MPMC             1162.5           7.2     0
+ * refusedQueue              MPSC              963.7           7.2     0
+ * refusedQueue              MPMC             1229.0           7.3     0
+ * refusedBuildThenOffer     MPSC              448.9         212.7     32
+ * refusedBuildThenOffer     MPMC              460.0         228.7     32
+ * refusedRaw                MPSC                3.4           2.5     0
+ * refusedRaw                MPMC                3.4           2.6     0
+ * steady                    MPSC              798.7         489.7     0
+ * steady                    MPMC             1008.4         252.8     0
+ * 
+ * + *

The {@code before} column predates two changes, not one: the MPMC rows were taken when that + * backing was a {@link java.util.concurrent.ConcurrentLinkedQueue} rather than an array ring. They + * are kept because the column exists to show the 120x, which is a property of the counter and not + * of the structure underneath it -- the refusal never reaches the backing at all. The {@code after} + * column is a single fresh run of everything, so the arms are comparable with each other. + * + *

What this measured. Two read-modify-writes on one shared line, taken by eight threads + * at the capacity boundary, cost about 120x what the same rejection costs when the first of them is + * a load instead. A refusal is now ~7.3ns against ~2.5ns for jctools' own producer-index CAS, so + * the permit counter costs on the order of 5ns over a bound the ring was already enforcing -- + * against ~960ns before, where it dwarfed everything else the API does. {@code steady} moved with + * it, and for the same reason: eight producers against one drain thread keep the queue saturated, + * so most of that arm is refusals too. + * + *

Treat the ratio with more suspicion than the direction. Two contended read-modify-writes + * should not cost 960ns on a quiet machine -- tens of nanoseconds is the expected order -- so some + * of that baseline is this machine's other work amplifying the contention, threads losing their + * slice mid-sequence with the line hot. The ~7.9ns is tight and the mechanism is not in doubt; a + * quiet run will likely show a smaller multiple against a smaller before. + * + *

{@code steady} is the one arm where the two backings now separate, and by more than their + * intervals: 252.8ns against 489.7ns, the MPMC ring ahead of the MPSC one. Eight producers against + * a single drain thread is a shape the multi-consumer ring is built for and the single-consumer + * ring is not, and the gap is not evidence about admission -- read it as the drain, not the claim. + * + *

Attribution, since two changes landed together: this is the read, not the folding of the + * closed flag into the count. Removing a volatile boolean load cannot account for 950ns. Folding it + * was structural -- one word of state instead of two that have to agree. + * + *

The premise pair, which now goes the way the module argues. {@code refusedProducer} + * against {@code refusedBuildThenOffer} is reserve-before-build against building first and finding + * out after: ~8ns and 0 B/op against ~422ns and 32 B/op. Before the read it was the awkward result + * -- 0 B/op but slower in {@code ns/op} -- because the counter cost more than the allocation it + * avoided. It no longer does. Read the pair for what it is even so: the build-then-offer arm has no + * counter and the producer arm has no allocation, so it is not one variable. What it establishes is + * the ordering, and the ordering has reversed. + */ +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@Threads(4) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@State(Scope.Benchmark) +public class ContendedAdmissionBenchmark { + + public enum Backings { + MPSC, + MPMC + } + + /** Big enough that the steady arm is not living at the boundary by accident. */ + private static final int CAPACITY = 1024; + + private static final String ELEMENT = "element"; + + /** + * Stands in for the element a real producer builds — a client-stats {@code SpanSnapshot} and its + * tag arrays. Its size is not meant to be exact; what matters is that it is a real allocation + * with a stable footprint, so the arm that builds one can be compared against the arm that does + * not. + */ + static final class Payload { + final Object a; + final Object b; + final long duration; + final int hash; + + Payload(Object a, Object b, long duration) { + this.a = a; + this.b = b; + this.duration = duration; + this.hash = a.hashCode() ^ b.hashCode(); + } + } + + /** Allocates on every call, and is only ever called once a place is already claimed. */ + private static final Producer BUILDER = + () -> new Payload(ELEMENT, ELEMENT, System.nanoTime()); + + @Param({"MPSC", "MPMC"}) + public Backings backings; + + /** Never full, drained concurrently by {@link #consume}. */ + private WorkQueue queue; + + /** Filled once in setup and never drained, so every admission is refused. */ + private WorkQueue full; + + /** The same, typed for the producer arm. */ + private WorkQueue fullPayloads; + + /** + * A full queue with no permit counter, for the two baselines. Unaffected by {@code backings} — it + * is the same number in both rows, and is what the MPSC backing would cost on the ring's own + * bound alone. + */ + private MpscArrayQueue raw; + + /** The one consumer for {@link #steady}. Owned here, not by JMH -- see that arm's note. */ + private Thread drain; + + private volatile boolean draining; + + @Setup + public void setUp() { + queue = create(CAPACITY); + full = create(16); + while (full.tryPut(ELEMENT)) { + // fill it, so claimPlace always has to back out + } + fullPayloads = + backings == Backings.MPSC ? WorkQueues.createMpscQueue(16) : WorkQueues.createMpmcQueue(16); + while (fullPayloads.tryPut(new Payload(ELEMENT, ELEMENT, 0L))) { + // same, for the producer arm + } + raw = new MpscArrayQueue<>(16); + while (raw.offer(ELEMENT)) { + // same, through jctools' own rejection + } + draining = true; + drain = + new Thread( + () -> { + while (draining) { + // Not timed, and deliberately not throttled: the point is to keep the steady arm + // off the capacity boundary and to keep the counter's increment side busy. + queue.process(CAPACITY, e -> {}); + } + }, + "contended-admission-drain"); + drain.setDaemon(true); + drain.start(); + } + + @TearDown + public void tearDown() throws InterruptedException { + draining = false; + drain.join(SECONDS.toMillis(5)); + } + + private WorkQueue create(int capacity) { + return backings == Backings.MPSC + ? WorkQueues.createMpscQueue(capacity) + : WorkQueues.createMpmcQueue(capacity); + } + + /** Reserve-before-build: the producer is never asked, so nothing is allocated. */ + @Benchmark + public void refusedProducer(Blackhole bh) { + bh.consume(fullPayloads.tryPut(BUILDER)); + } + + /** + * Build-then-offer: what the same rejection costs when the element is constructed before the + * queue gets a say. Consumed through the blackhole so it genuinely escapes, the way an element + * handed to a queue in another class does — otherwise escape analysis erases the allocation this + * arm exists to charge for. + */ + @Benchmark + public void refusedBuildThenOffer(Blackhole bh) { + Payload payload = new Payload(ELEMENT, ELEMENT, System.nanoTime()); + bh.consume(raw.offer(payload)); + bh.consume(payload); + } + + /** Two RMWs on the shared counter, every call, from every thread. */ + @Benchmark + public void refusedQueue(Blackhole bh) { + bh.consume(full.tryPut(ELEMENT)); + } + + /** The bound jctools gives for free, for the delta. */ + @Benchmark + public void refusedRaw(Blackhole bh) { + bh.consume(raw.offer(ELEMENT)); + } + + /** + * The commoner half: not full, with a consumer making room about as fast as producers take it, so + * the counter is incremented by a drain while it is decremented by admission -- contending on the + * same line from both directions. + * + *

Every JMH thread is a producer here, and the consumer is the dedicated {@link #drain} thread + * started in setup rather than a {@code @Group} member. That is not a stylistic choice. A group's + * {@code @GroupThreads(1)} fixes the consumer count *per group*, and JMH instantiates as many + * groups as the thread count allows -- so {@code -Pjmh.threads=8} against a group of 4 yields two + * consumers. Two concurrent {@code poll()}s on the single-consumer MPSC ring do not fail; they + * spin inside jctools' gap-wait and the iteration never ends. Owning the consumer outright makes + * the arm correct at any thread count, which is what the project's spot-check flags hand it. + */ + @Benchmark + public void steady(Blackhole bh) { + bh.consume(queue.tryPut(ELEMENT)); + } +} diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedBatchAdmissionBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedBatchAdmissionBenchmark.java new file mode 100644 index 00000000000..4685918ae66 --- /dev/null +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedBatchAdmissionBenchmark.java @@ -0,0 +1,231 @@ +package datadog.common.queue; + +import static java.util.concurrent.TimeUnit.SECONDS; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.AuxCounters; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Group; +import org.openjdk.jmh.annotations.GroupThreads; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * What a batching producer costs the producers admitting next to it, which is the half of batch + * claiming that its own throughput number cannot show. {@link BatchAdmissionBenchmark} measures + * what the batcher saves; this measures who pays for it. + * + *

+ * ./gradlew :utils:queue-utils:jmh -Pjmh.includes=ContendedBatchAdmission
+ * 
+ * + *

The mechanism being priced. A claim spends places by driving the shared count down and + * gives back what it could not use, and every other producer's admission opens by comparing that + * same count against zero. So while a claim of {@code n} is outstanding the count reads {@code n} + * lower than it is, and a neighbour arriving at a queue with fewer than {@code n} places left is + * turned away by a load -- correctly, in the sense that the places really were spoken for, and + * spuriously, in the sense that most of them are about to come back. That window is why {@link + * BaseWorkQueue} caps a claim rather than taking the whole batch at once. + * + *

The shape. Two groups of four threads, differing only in what the first thread does. In + * {@code batched} it admits {@code batchSize} elements with one {@code tryPutBatch}; in {@code + * unbatched} it admits the same elements with a loop of {@code tryPut}. The other three threads are + * identical in both: a single {@code tryPut} each, and they are the measurement. The comparison + * that answers the question is {@code neighbourOfBatch} against {@code neighbourOfLoop} -- same + * work, same thread count, same queue, and the only difference is how the fourth thread claims. + * + *

Read the neighbour arms as a pair and the producer arms as a pair; across the two pairs the + * units differ, because a batch call admits {@code batchSize} elements and a single call admits + * one. + * + *

What would count as too large a cap. If {@code neighbourOfBatch} is materially slower + * than {@code neighbourOfLoop}, or refuses materially more often, the cap is buying the batcher's + * throughput out of its neighbours' -- which is a trade a shared queue should not make quietly. + * {@code refused} is reported alongside for that reason: a neighbour turned away costs almost + * nothing in time and everything in outcome, so the timing alone would hide it. + * + *

The consumer is a thread this class owns rather than a group member, for the reason {@link + * ContendedAdmissionBenchmark#steady} sets out: a group's consumer count scales with the thread + * count, and two concurrent polls on the single-consumer MPSC ring hang inside jctools' gap-wait. + * + *

Results. Four threads, one fork, five iterations, JDK 25, on a machine with other work + * on it. The intervals are wide -- often half the mean -- so read the producer column for its + * direction and the neighbour columns with real suspicion. {@code refused%} is {@code refused} over + * {@code attempts}, both summed over the iteration. + * + *

+ * (backings) (batchSize)   producer ns/op   neighbour ns/op   neighbour refused%
+ * MPSC        8    batched         1038.3             232.7                 67.6
+ * MPSC        8    looping         2331.8             294.9                 49.2
+ * MPSC        32   batched         1637.3             213.2                 67.6
+ * MPSC        32   looping         7392.9             221.2                 68.0
+ * MPMC        8    batched          705.1             170.7                 81.4
+ * MPMC        8    looping         1479.7             185.6                 79.7
+ * MPMC        32   batched         1389.4             186.9                 78.5
+ * MPMC        32   looping         3894.4             121.0                 89.1
+ * 
+ * + *

The producer column is the finding. Batching wins everywhere it is contended, by 2.1x + * at eight elements on MPMC and by 4.5x at thirty-two on MPSC, and the advantage grows with the + * batch -- the opposite of {@link BatchAdmissionBenchmark}, where the same code at one thread is + * about 2ns per element slower. That is the whole case for batch claiming stated in two tables: it + * buys nothing from the instruction it removes and a great deal from the cache line it stops + * touching. + * + *

The neighbour columns do not resolve. The timings move in opposite directions on the + * two backings and every gap sits inside its own interval. The refusal rates do have a shape -- + * every neighbour refuses more often the faster its neighbour admits -- but that is not the claim + * window, or not only. One consumer bounds total admissions, so a producer that admits three times + * faster takes three times the share, and its neighbours find the queue full more often. That is + * the batcher succeeding, not the cap leaking. + * + *

Which means this benchmark does not yet separate the two effects it was built to tell apart: a + * neighbour refused because places are transiently claimed, and a neighbour refused because + * somebody else's work got in first. Distinguishing them wants a quiet machine and a consumer fast + * enough that the queue is not the bottleneck. Until then {@code MAX_BATCH_CLAIM} rests on the + * argument rather than the measurement -- the dip is real and bounded by the cap whether or not + * this run can see it. + */ +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@State(Scope.Benchmark) +public class ContendedBatchAdmissionBenchmark { + + public enum Backings { + MPSC, + MPMC + } + + /** + * A neighbour turned away costs almost nothing in time and everything in outcome, so the timing + * alone would hide the effect this class exists to find -- a batch that makes its neighbours + * refuse looks, in {@code ns/op}, like a batch that costs them nothing. + */ + @State(Scope.Thread) + @AuxCounters(AuxCounters.Type.EVENTS) + public static class Outcomes { + public long refused; + + /** + * Reported alongside {@link #refused} because the counter is a sum, not a rate, and the two + * arms do not run the same number of operations -- a neighbour that got faster refused more + * times for that reason alone. The ratio is the comparable number. + */ + public long attempts; + + @Setup(Level.Iteration) + public void reset() { + refused = 0; + attempts = 0; + } + } + + /** + * Deliberately not much larger than a batch. The whole effect lives at the boundary: a queue with + * room to spare absorbs an outstanding claim without any neighbour noticing. + */ + private static final int CAPACITY = 256; + + private static final String ELEMENT = "element"; + + @Param({"MPSC", "MPMC"}) + public Backings backings; + + @Param({"8", "32"}) + public int batchSize; + + private WorkQueue queue; + + private List elements; + + /** The one consumer. Owned here, not by JMH -- see the class note. */ + private Thread drain; + + private volatile boolean draining; + + @Setup + public void setUp() { + queue = + backings == Backings.MPSC + ? WorkQueues.createMpscQueue(CAPACITY) + : WorkQueues.createMpmcQueue(CAPACITY); + elements = new ArrayList<>(batchSize); + for (int i = 0; i < batchSize; i++) { + elements.add(ELEMENT); + } + draining = true; + drain = + new Thread( + () -> { + while (draining) { + // Unthrottled: the arms are about who gets in, not about starving the queue. + queue.process(CAPACITY, e -> {}); + } + }, + "contended-batch-admission-drain"); + drain.setDaemon(true); + drain.start(); + } + + @TearDown + public void tearDown() throws InterruptedException { + draining = false; + drain.join(SECONDS.toMillis(5)); + } + + /** One claim for the whole run, so the count dips by the claim's width until the rest is back. */ + @Benchmark + @Group("batched") + @GroupThreads(1) + public void batchedProducer(Blackhole bh) { + bh.consume(queue.tryPutBatch(elements)); + } + + /** The measurement: an ordinary producer, alongside a batching one. */ + @Benchmark + @Group("batched") + @GroupThreads(3) + public void neighbourOfBatch(Outcomes outcomes) { + outcomes.attempts++; + if (!queue.tryPut(ELEMENT)) { + outcomes.refused++; + } + } + + /** The same admissions as {@link #batchedProducer}, one claim each: the control. */ + @Benchmark + @Group("unbatched") + @GroupThreads(1) + public void loopingProducer(Blackhole bh) { + for (int i = 0; i < batchSize; i++) { + bh.consume(queue.tryPut(elements.get(i))); + } + } + + /** The same measurement, with nothing batching beside it. */ + @Benchmark + @Group("unbatched") + @GroupThreads(3) + public void neighbourOfLoop(Outcomes outcomes) { + outcomes.attempts++; + if (!queue.tryPut(ELEMENT)) { + outcomes.refused++; + } + } +} diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/RetryLeaseBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/RetryLeaseBenchmark.java new file mode 100644 index 00000000000..6dd3a0fbfbc --- /dev/null +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/RetryLeaseBenchmark.java @@ -0,0 +1,277 @@ +package datadog.common.queue; + +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * What the retry lease costs, and whether it costs it only on the failure path. + * + *

./gradlew :utils:queue-utils:jmh -Pjmh.includes=RetryLease -Pjmh.profilers=gc
+ * + *

{@code BaseWorkQueue.lease} allocates an anonymous {@link RetryQueue} per failure, capturing + * the queue and the attempt number. The alternative is one lease per queue held in a field, which + * allocates never -- but a field cannot carry a per-item attempt number without either a mutable + * field (a race the moment {@code createMpmcQueue} gives the queue a second consumer) or an API + * change putting {@code attempt} back into {@link RetryQueue#retry}. The question this answers is + * whether that trade is even on the table, or whether escape analysis already deletes the + * allocation. + * + *

The lease is handed to {@link RetryStrategy#onFailure}, which is user code behind an + * interface. C2 can only prove the lease does not escape by inlining {@code onFailure} and seeing + * what it does, so the answer should depend on how many strategy types the process has loaded -- + * hence the {@code strategies} parameter, which loads one, two, or four. + * + *

{@code gc.alloc.rate.norm} is the number that answers it. The thrown exception is a + * preallocated, stackless singleton so that its own allocation does not swamp the lease's. + * + *

Results: + * + *

+ * gc.alloc.rate.norm, B/op            ONE   TWO   FOUR      ns/op
+ * succeeds                              0     0      0       21.3
+ * failsAndGivesUp (static final)        0     0      0       22.8
+ * failsAndGivesUpInline (lambda)        0     0      0       22.4
+ * failsAndGivesUpCapturing              0     0      0       21.2
+ * failsAndGivesUpExactField             0     0      0       22.4
+ * failsAndGivesUpVirtual (iface field)  0     0     24       22.6
+ * failsAndRetries                      24    24     24       41.6
+ * failsWithHandler (control)            0     0      0       22.5
+ * 
+ * + *

JDK 25, {@code -Pjmh.forks=2}. Three things are visible, and only the third was the one being + * looked for. + * + *

What the strategy is bound to decides everything. Five failure arms run the same policy + * and differ only in how the call site reaches it. Four cost nothing at any number of loaded + * implementations, by two different routes. Three of them make the value known: a {@code + * static final} field is a trusted constant; an inline non-capturing lambda links through a {@code + * ConstantCallSite} and folds to the same thing without a field to declare; an inline + * capturing lambda folds to nothing at all, but its allocation site sits in the caller, so + * the exact class is visible anyway and the capture scalar-replaces. The fourth makes the + * type known: {@code failsAndGivesUpExactField} reads a deliberately non-final field + * declared at a concrete final class, where every value it could hold has the same klass. Only + * {@code failsAndGivesUpVirtual}, an interface-typed field, has neither and must ask the receiver + * profile -- which at {@code FOUR} has nothing useful to say. + * + *

The two routes are not interchangeable, and which one is available depends on the situation + * rather than on taste. A per-instance policy cannot use the value route at all: folding {@code + * this.strategy} needs the holder to be a constant too, so no amount of finality helps an object + * allocated at runtime. It has only the type route -- which means a named class, because a lambda's + * class is unnameable and the tightest a field holding one can be declared is the abstract + * interface. A shared policy can use either. So: singleton policy, {@code static final} or an + * inline lambda; per-instance policy, a named final class with the field declared at it. The + * failure in this table is what trying to use the first shape for the second situation looks like. + * + *

Note also what {@code failsAndGivesUpCapturing} does not say. Its capture is free + * because everything around it inlined; store that same lambda into a field and read it back and + * both the capture and the devirtualization are gone. + * + *

The lease costs 24 bytes, in one shape. Those 24 bytes -- an object header, the + * captured queue, the captured attempt number -- appear only in the virtual arm at {@code FOUR}, + * which is exactly where {@code onFailure} stopped inlining and C2 could no longer see that the + * lease does not escape. {@code failsWithHandler} takes the same throw down the {@link + * ExceptionHandler} branch, which is never handed a lease, and reads zero throughout, so the 24 + * bytes are the lease rather than something else on the failure path. + * + *

Retrying costs 24 bytes always. {@code failsAndRetries} allocates at every type count, + * including where the lease is provably free. That is the {@code Retry} wrapper the re-admitted + * item is boxed in, not the lease, and only an item actually resubmitted pays it. + * + *

Timing says nothing either way; every arm that fails once is within noise of every other, and + * an allocation this size is invisible next to a throw. + */ +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@Threads(1) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@State(Scope.Thread) +public class RetryLeaseBenchmark { + + public enum Strategies { + ONE, + TWO, + FOUR + } + + private static final String ELEMENT = "element"; + + /** Stackless and shared, so the failure path's own allocation is not what gets measured. */ + private static final RuntimeException FAILURE = + new RuntimeException("consumer failed", null, false, false) {}; + + private static final Consumer SUCCEEDS = item -> {}; + + private static final Consumer THROWS = + item -> { + throw FAILURE; + }; + + /** Gives up without touching the lease: the lease is allocated, passed, and never used. */ + private static final RetryStrategy GIVE_UP = (item, attempt, failure, queue) -> false; + + /** Uses the lease, which is what a strategy is handed one for. */ + private static final RetryStrategy RETRY_ONCE = + (item, attempt, failure, queue) -> attempt <= 1 && queue.retry(item); + + private static final ExceptionHandler HANDLER = (item, failure) -> {}; + + /** + * The same {@link #GIVE_UP} strategy, reached through a non-final instance field instead of a + * {@code static final} one. Only the binding differs, and it is the whole experiment: a constant + * strategy tells C2 the exact receiver type outright, so {@code onFailure} devirtualizes by + * static resolution no matter how many strategy types the process has loaded. Reading it from a + * field takes that away and leaves the shared receiver profile as the only evidence C2 has. + */ + private RetryStrategy bound; + + /** Two more strategy types, loaded to move the {@code onFailure} call site off monomorphic. */ + private static final RetryStrategy THIRD = + new RetryStrategy() { + @Override + public boolean onFailure(String i, int attempt, Throwable f, RetryQueue q) { + return false; + } + }; + + private static final RetryStrategy FOURTH = + new RetryStrategy() { + @Override + public boolean onFailure(String i, int attempt, Throwable f, RetryQueue q) { + return false; + } + }; + + @Param({"ONE", "TWO", "FOUR"}) + public Strategies strategies; + + private WorkQueue queue; + + private WorkQueue other; + + /** + * The same policy as {@link #GIVE_UP}, written as a named final class so a field can be declared + * at its exact type. A lambda cannot be used here: its class is unnameable, so the tightest a + * field holding one can be declared is the functional interface. + */ + static final class GiveUp implements RetryStrategy { + @Override + public boolean onFailure(String i, int attempt, Throwable f, RetryQueue q) { + return false; + } + } + + /** + * Deliberately not {@code final}: the point of the arm is that the declared type carries + * the answer. {@link GiveUp} is a final class, so every value this field can hold has exactly + * that klass, and C2 resolves {@code onFailure} off the type without needing to trust the value. + */ + private GiveUp exact; + + /** Captured by {@link #failsAndGivesUpCapturing}, so that lambda cannot be hoisted. */ + private int mixer; + + @Setup + public void setUp(Blackhole bh) { + queue = WorkQueues.createMpscQueue(1024); + bound = GIVE_UP; + exact = new GiveUp(); + other = WorkQueues.createMpscQueue(1024); + // Drive the extra strategy types through the same onFailure site the measured loop uses, so + // the only thing the parameter varies is how many types that site has seen. + for (int i = 0; i < 50_000; i++) { + if (strategies != Strategies.ONE) { + other.tryPut(ELEMENT); + other.processOrRetry(THROWS, THIRD); + } + if (strategies == Strategies.FOUR) { + other.tryPut(ELEMENT); + other.processOrRetry(THROWS, FOURTH); + other.tryPut(ELEMENT); + other.processOrRetry(THROWS, RETRY_ONCE); + other.process(bh::consume); + } + } + } + + @Benchmark + public boolean succeeds() { + queue.tryPut(ELEMENT); + return queue.processOrRetry(SUCCEEDS, GIVE_UP); + } + + @Benchmark + public boolean failsAndGivesUp() { + queue.tryPut(ELEMENT); + return queue.processOrRetry(THROWS, GIVE_UP); + } + + /** Retried once, then given up on, so the queue is empty again at the end of every invocation. */ + @Benchmark + public boolean failsAndRetries() { + queue.tryPut(ELEMENT); + boolean first = queue.processOrRetry(THROWS, RETRY_ONCE); + return first & queue.processOrRetry(THROWS, RETRY_ONCE); + } + + @Benchmark + public boolean failsAndGivesUpVirtual() { + queue.tryPut(ELEMENT); + return queue.processOrRetry(THROWS, bound); + } + + /** + * The same strategy written at the call site instead of stored anywhere. A non-capturing lambda + * links through a {@code ConstantCallSite}, so the {@code invokedynamic} folds to a constant oop + * of exact type -- the same thing a {@code static final} field gives C2, without the field. + */ + @Benchmark + public boolean failsAndGivesUpInline() { + queue.tryPut(ELEMENT); + return queue.processOrRetry(THROWS, (item, attempt, failure, q) -> false); + } + + /** + * The same lambda made capturing, which is the mistake the inline form invites. It has to be + * built per call, so nothing folds to a constant -- but the allocation site is still exactly + * typed, so how much that costs is a separate question from devirtualization. + */ + @Benchmark + public boolean failsAndGivesUpCapturing() { + queue.tryPut(ELEMENT); + int floor = mixer++; + return queue.processOrRetry(THROWS, (item, attempt, failure, q) -> attempt < floor); + } + + /** + * A field again, but declared at a concrete final class instead of the interface -- the shape + * {@link #failsAndGivesUpVirtual} is missing, and the only way a per-instance strategy can be + * devirtualized, since an instance field's value is never a trusted constant. + */ + @Benchmark + public boolean failsAndGivesUpExactField() { + queue.tryPut(ELEMENT); + return queue.processOrRetry(THROWS, exact); + } + + /** The control: the same throw down the handler branch, which is never handed a lease. */ + @Benchmark + public boolean failsWithHandler() { + queue.tryPut(ELEMENT); + return queue.processOrHandle(THROWS, HANDLER); + } +} diff --git a/utils/queue-utils/src/jmh/java/datadog/common/queue/ThirdBackingWorkQueue.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/ThirdBackingWorkQueue.java new file mode 100644 index 00000000000..082c70fea3e --- /dev/null +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/ThirdBackingWorkQueue.java @@ -0,0 +1,36 @@ +package datadog.common.queue; + +import org.jctools.queues.MessagePassingQueue; + +/** + * A third concrete {@link BaseWorkQueue}, existing only so a benchmark can put three types into the + * profile of {@code store} and {@code retrieve}. + * + *

It is a real backing rather than a stub — an SPSC ring, driven from one thread — because a + * stub whose {@code store} folded away would measure the dispatch and not the cliff. Kept in the + * benchmark source set: the claim in {@link BaseWorkQueue#store} is about what a third backing + * would cost, and shipping one to prove it would make the claim false. + */ +final class ThirdBackingWorkQueue extends BaseWorkQueue { + + private final MessagePassingQueue queue; + + ThirdBackingWorkQueue(int requestedCapacity) { + this(Queues.spscArrayQueue(requestedCapacity)); + } + + private ThirdBackingWorkQueue(MessagePassingQueue queue) { + super(queue.capacity()); + this.queue = queue; + } + + @Override + boolean store(Object element) { + return queue.offer(element); + } + + @Override + Object retrieve() { + return queue.poll(); + } +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java new file mode 100644 index 00000000000..18048780bd8 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -0,0 +1,870 @@ +package datadog.common.queue; + +import static java.util.Collections.emptyList; + +import datadog.trace.api.function.Strategy; +import datadog.trace.api.function.StrategyConsumer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * Everything a {@link WorkQueue} does that does not depend on how elements are stored: the bound, + * admission, reservations, the closed state, and the consume-and-maybe-retry cycle. + * + *

Subclasses supply two storage primitives, {@link #store} and {@link #retrieve}, and neither + * needs to enforce anything. The bound lives here, as a count of places still available: admission + * spends one before it builds or stores anything, consumption returns one, and a reservation is + * simply a spent place with nothing in it yet. That is why the storage primitives can be as thin as + * they are, and why both backings admit and reserve through exactly the same code. + * + *

The counter costs one atomic add per admission and one per consumption. On a backing that + * could have leaned on its own bound that is a real tax, paid for a uniform contract: every backing + * can reserve capacity, nothing has to hold a position open, so no consumer can be stalled by a + * reservation and no reservation can deadlock a thread that also consumes. + */ +abstract class BaseWorkQueue implements WorkQueue { + + /** + * Wraps an item that has already failed, carrying its attempt count back into the queue. Only + * allocated on the failure path, so the common case stores the element itself. + */ + private static final class Retry { + final T item; + final int attempt; + + Retry(T item, int attempt) { + this.item = item; + this.attempt = attempt; + } + } + + /** Non-capturing adapters, so the producer forms share one admission path without allocating. */ + private static final ContextualProducer, Object> PRODUCE = Producer::produce; + + /** + * Subtracted from {@link #state} once, by {@link #close()}. Closing then costs no flag of its + * own: it drives the permit count so far negative that no claim can ever succeed again, so a + * closed queue refuses through the same comparison that a full one does and admission has one + * word to read instead of two that have to agree. + * + *

Large enough to be unreachable from either side. Permits start at no more than {@link + * Integer#MAX_VALUE} and only places actually claimed are ever given back, so releases after a + * close cannot climb the offset; and the count is a {@code long} precisely because an unbounded + * queue seeds it with {@code Integer.MAX_VALUE}, which leaves an {@code int} no room above the + * bound to put this. + */ + private static final long CLOSED_OFFSET = 1L << 40; + + /** Any state below this has {@link #CLOSED_OFFSET} applied to it, and nothing else can be. */ + private static final long CLOSED_MARK = -(1L << 39); + + /** + * The most places one call will claim at a time, however long the batch. + * + *

This is not a fairness knob. A claim drives the shared count down by its own size until the + * unused part comes back, and every other producer's admission begins with a comparison of that + * count against zero -- so an oversized claim turns concurrent single admissions away for the + * width of the claim, at the boundary, where the most threads are arriving. The cap is a bound on + * how many neighbours one batching producer can make refuse, which is why it is modest rather + * than as large as batches get. Claims are also clamped to what the count says is available, so + * this bounds the dip a claim takes deliberately; the rest is the race the single claim already + * runs. {@code ContendedAdmissionBenchmark} prices both halves: what batching saves the batcher, + * and what it costs the producers next to it. + */ + private static final int MAX_BATCH_CLAIM = 32; + + /** + * Places still available, not places used, biased by {@link #CLOSED_OFFSET} once closed. The + * bound is then a comparison against zero rather than against a capacity that has to be loaded + * and that an unbounded queue has to be branched around: seeded with {@link Integer#MAX_VALUE} it + * is a queue no backlog can exhaust, on the same code path as any other. + */ + private final AtomicLong state; + + private final int capacity; + + BaseWorkQueue(int capacity) { + this.capacity = capacity; + this.state = new AtomicLong(capacity); + } + + /** The places left, with the closed bias taken back off. */ + private static long permits(long state) { + return state < CLOSED_MARK ? state + CLOSED_OFFSET : state; + } + + /** + * Stores an element in a place already claimed for it, so this can only fail if the backing + * refuses for a reason of its own. + * + *

A place has already been claimed, so a backing that can refuse transiently is expected to + * retry rather than report the refusal — a {@code false} from here loses the element, and there + * is no way for the queue to tell a structure saying "full" from one saying "not yet". {@link + * MpmcWorkQueue} is the case that matters. + * + *

What a third backing would cost here

+ * + *

This is one call site shared by every backing in the process, so its receiver profile is + * global rather than per-caller: a queue used nowhere near yours still writes into it. At one or + * two implementations C2 inlines through it; at three it stops, and {@code -XX:+PrintInlining} + * reports {@code store} and {@code retrieve} as {@code failed to inline: virtual call} on both + * JDK 17 and JDK 25. So the cliff is real and it is easy to fall off — a third backing is a + * decision about every existing caller, not only about its own. + * + *

It is also, measured, worth one or two nanoseconds. {@code AdmissionBenchmark}'s {@code + * THREE} arm is the standing version of that experiment: 20.8ns against 21.8ns per + * admit-and-drain for {@code tryPut(Producer)}, the same shape across the other admission forms, + * and no allocation difference at all. The reason is that an admit-and-drain pays two uncontended + * atomics on the permit count and a compare-and-set inside the ring, and an out-of-line call is + * little against memory ordering. Contention widens the atomics and narrows this further, and + * batching does not change it either, because the drain still returns a place per element. + * + *

One path is worse: admitting through a {@link Reservation} costs about 30% more, because + * {@link Reservation#fill}'s {@code store} sits at the end of a chain of optimizations that has + * to survive a call that no longer folds away. The reservation is still scalar-replaced, so it is + * time and not garbage — but a caller admitting that way is the one with something to lose. + * + *

Which is the useful form of the warning. Do not add a third backing to buy throughput, + * because there is none here to buy; weigh it against a few percent and against the reader. And + * if a path ever admits without touching an atomic, measure again there, because that is where + * this would start to matter. The repair, if it comes to it, is to stop sharing the site: let + * each backing implement the public interface and forward into this class, so the receiver is an + * exact final type at the top of the inlining tree and the call devirtualizes by static + * resolution rather than by profile. + * + *

None of that applies to a backing that holds two structures behind one type, which is + * why {@link MpmcWorkQueue} branches on a field of its own rather than arriving here as two + * classes. A predictable branch costs nothing and adds no receiver. + * + * @return whether the element was stored + */ + abstract boolean store(Object element); + + /** + * @return the next stored object, or {@code null} if there was none + */ + abstract Object retrieve(); + + /** + * How many times {@link #discardAll} will re-read a backing that says empty while the count says + * otherwise, before it believes the backing. + */ + private static final int DRAIN_ATTEMPTS = 64; + + /** + * Spends a place, and gives it back if there was none to spend, rather than looping on a + * compare-and-set. Admission costs one atomic add, with a second only on the path that was going + * to be rejected anyway — and no retry under contention, which is where a CAS loop is at its + * worst. + * + *

A plain read comes first, and it is what makes a refusal cheap. Without it a rejected + * admission paid two read-modify-writes on the one line every producer is already fighting over, + * at the capacity boundary, which is exactly where the most threads arrive at once -- {@code + * ContendedAdmissionBenchmark} priced that at roughly 960ns against 3.4ns for the same rejection + * taken on the backing's own producer index. A queue that is full, or closed, now turns a + * claimant away with a load. The decrement stays authoritative, so the bound is unaffected: the + * read can only cause a refusal, never an admission. + * + *

The bound itself is exact: the queue never holds more than {@code capacity} elements and + * open reservations together. What is approximate is who gets turned away. Claimants racing at + * the boundary can drive the count below zero between them and all give their places back, so an + * admission can be rejected while the queue is a place or two short of full. That only happens + * when it is already at the boundary, where the caller is dropping work regardless. + */ + private boolean claimPlace() { + if (state.get() < 1) { + return false; + } + if (state.decrementAndGet() >= 0) { + return true; + } + state.incrementAndGet(); + return false; + } + + private void releasePlace() { + state.incrementAndGet(); + } + + /** + * Spends up to {@code wanted} places at once, and grants what was there rather than refusing + * because all of it was not. {@link #claimPlace} is this sequence with {@code wanted} of one. + * + *

A batch that claimed all-or-nothing would refuse the whole call when one place was short, + * which is a refusal the caller cannot distinguish from a full queue and cannot act on -- it had + * room for all but one. Granting the shortfall exactly removes that outcome by construction, + * without a retry at a smaller size: the initial read already says how large the claim can + * usefully be, so the scaling down is a clamp against a load this path was paying anyway, not a + * second trip. + * + *

The shape is {@link #claimPlace}'s, and deliberately so: one atomic add on the common path, + * a second only where the count went negative, and no compare-and-set loop. It reads {@link + * #state} raw rather than through {@link #permits}, for the same reason that one does -- a closed + * queue is biased far below zero, so it fails the first comparison and is turned away by a load, + * and a close landing mid-claim drives the count so negative that the whole claim is given back. + * + *

What a short grant means is therefore narrower than it looks. It is not proof the queue is + * full: concurrent claimants can each back out and leave places neither of them took. Callers + * loop until a claim comes back empty rather than treating the first short grant as the end. + * + * @return the places granted, between zero and {@code wanted} + */ + private int claimPlaces(int wanted) { + long available = state.get(); + if (available < 1) { + return 0; + } + int ask = (int) Math.min(wanted, available); + long after = state.addAndGet(-ask); + if (after >= 0) { + return ask; + } + // Short by exactly the deficit -- and if a close landed in between, the deficit is the whole + // claim, so the offset is restored untouched. + int give = (int) Math.min(-after, ask); + state.addAndGet(give); + return ask - give; + } + + private void releasePlaces(int places) { + if (places != 0) { + state.addAndGet(places); + } + } + + /** + * Shared with the retry path, where a refusal is a step rather than an outcome: a strategy handed + * a refused retry may still place the item somewhere else, and only its {@link + * RetryStrategy#onFailure} return says whether the item was finally lost. + */ + private boolean admit(Object element) { + if (!claimPlace()) { + return false; + } + if (store(element)) { + return true; + } + releasePlace(); + return false; + } + + @StrategyConsumer + private boolean admit( + C context, @Strategy ContextualProducer 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: a backing is allowed + // to give up -- MpmcWorkQueue does, past its retry bound -- and a place spent on an element + // the backing would not take has to come back exactly once. Discarding this return was + // safe only while no backing could refuse an element it had already claimed room for. + queue.storeOrRelease(element); + } + + @Override + public void close() { + // Only the reserving thread fills or closes, so a plain flag orders the two correctly. + if (!done) { + done = true; + queue.releasePlace(); + } + } + } + + private Object take() { + Object element = retrieve(); + if (element != null) { + releasePlace(); + } + return element; + } + + /** + * Empties the queue, giving every place back as it goes. + * + *

An empty read is not taken at face value while {@link #size} still reports work, because the + * two can disagree honestly: {@code size} counts places claimed by producers that have not stored + * into the backing yet, so an element on its way in is owed to this drain and is not visible to + * it. Stopping at the first empty read would leave that element behind holding its place, which + * for {@link #clear} and {@link #shutdown} is the difference between emptying the queue and + * appearing to. The re-read is bounded for the same reason it is needed: the producer whose place + * this is may be descheduled, or may never store at all, and would otherwise keep this loop here + * forever. + */ + private void discardAll() { + int emptyReads = 0; + while (true) { + if (take() != null) { + emptyReads = 0; + } else if (size() <= 0 || ++emptyReads >= DRAIN_ATTEMPTS) { + return; + } + } + } + + @Override + public final int size() { + // Claimants at the boundary can transiently drive the permit count out of range in either + // direction -- below zero before they back out, and above the count anyone was granted -- so + // the report is clamped rather than allowed to say more than capacity or less than nothing. + return (int) Math.min(capacity, Math.max(0, capacity - permits(state.get()))); + } + + @Override + public final boolean tryPut(T element) { + requireElement(element); + return admit(element); + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public final boolean tryPut(Producer 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..2e13dbefe16 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MaxRetries.java @@ -0,0 +1,23 @@ +package datadog.common.queue; + +/** + * A {@link RetryStrategy} that resubmits a failed item up to a fixed number of times, then gives + * up. + * + *

The count is retries, not consumptions: {@code new MaxRetries<>(3)} lets an item be consumed + * four times in all, and {@code new MaxRetries<>(0)} never resubmits. {@link + * RetryStrategy#onFailure} reports the first failure as attempt {@code 1}, so the comparison is + * against the failures so far rather than against the attempt number. + */ +public final class MaxRetries implements RetryStrategy { + private final int maxRetries; + + public MaxRetries(int maxRetries) { + this.maxRetries = maxRetries; + } + + @Override + public boolean onFailure(T item, int attempt, Throwable failure, RetryQueue retryQueue) { + return attempt <= maxRetries && retryQueue.retry(item); + } +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/MpmcWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/MpmcWorkQueue.java new file mode 100644 index 00000000000..a5f72792a3a --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MpmcWorkQueue.java @@ -0,0 +1,110 @@ +package datadog.common.queue; + +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import org.jctools.queues.MessagePassingQueue; + +/** + * A {@link WorkQueue} for several consumers: an MPMC array queue when bounded, a {@link + * ConcurrentLinkedQueue} when not. + * + *

Storage only, as with {@link MpscWorkQueue} — the bound and the reserve-before-construct + * guarantee live in {@link BaseWorkQueue}. One class covers both structures on purpose. They are + * different enough to want different types, but {@code store} and {@code retrieve} are the sites + * every admission and every drain funnels through, and a third implementation of those makes them + * megamorphic for callers that only ever touch one backing — see {@link BaseWorkQueue#store}. A + * predictable branch on a final field inside this class is much the cheaper way to hold two + * structures, and only multi-consumer callers reach it at all. + * + *

Bounded is array-backed because that is the better structure: measured against the linked + * queue at 12.7ns against 20.6ns per admit-and-drain, and no per-element node, so it stops + * manufacturing 24 bytes of garbage per element. Unbounded stays linked because JCTools' unbounded + * MPMC queue exists only in its {@code Unsafe} form, and {@link Queues} deliberately moves off + * {@code Unsafe} on Java 25 and later. + * + *

The bounded backing is a JCTools MPMC ring; consult the JCTools documentation for its + * semantics. What matters here is that its {@code offer} can refuse a queue that is not full, and + * that the bound does not live in the ring: a place is claimed before {@link #store} is ever + * called, so a refusal can only mean not yet, and retrying will succeed. The retry is bounded + * anyway — if the accounting were ever wrong, an unbounded spin would turn a bug into a hang. See + * {@link Queues#mpmcArrayQueue} for why an ordinary caller cannot use the ring this way. + */ +final class MpmcWorkQueue extends BaseWorkQueue { + + /** + * The smallest ring JCTools will build. A caller asking for one place gets two rather than an + * {@link IllegalArgumentException}, in the spirit of rounding the capacity up. + */ + private static final int MINIMUM_CAPACITY = 2; + + /** + * How many times a claimed place will re-offer before giving up on it. + * + *

The window it is riding out is one thread's publish, so almost every retry that happens at + * all succeeds on its next attempt. The yields are for the case the spin cannot fix — a producer + * descheduled between claiming its slot and filling it — where spinning without giving the core + * up would just burn the window down. + */ + private static final int STORE_ATTEMPTS = 64; + + private static final int YIELD_EVERY = 16; + + private final Queue queue; + + /** Bounded, and array-backed: the reason this class exists. */ + @SuppressWarnings("unchecked") + static MpmcWorkQueue bounded(int requestedCapacity) { + MessagePassingQueue ring = + Queues.mpmcArrayQueue(Math.max(MINIMUM_CAPACITY, requestedCapacity)); + // Every JCTools array queue is an AbstractQueue; the cast is checked once, here, and never on + // the admission path. The bound is the capacity the ring actually rounded up to, not the one + // that was asked for, so the counter and the ring can never disagree about what full means. + return new MpmcWorkQueue<>((Queue) ring, ring.capacity()); + } + + /** + * Unbounded, for call sites that have no defensible capacity yet. Admission never rejects, so the + * queue buys the lifecycle and the interface and nothing else; it is a step on the way to picking + * a bound, not a destination. + */ + static MpmcWorkQueue unbounded() { + return new MpmcWorkQueue<>(new ConcurrentLinkedQueue<>(), Integer.MAX_VALUE); + } + + private MpmcWorkQueue(Queue queue, int capacity) { + super(capacity); + this.queue = queue; + } + + @Override + boolean store(Object element) { + // Small enough to inline into the admission path, which is the only reason the retry is a + // separate method: a loop here made this too big for C2 to inline and cost every admission + // more than the array ring was saving them. + return queue.offer(element) || storeRetrying(element); + } + + /** + * The refusal is transient by construction — a place was claimed, so a slot exists. Reached + * roughly once in four hundred offers with four producers and four consumers on a ring of eight, + * and not at all on a linked queue, which never refuses. + */ + private boolean storeRetrying(Object element) { + for (int attempt = 1; attempt < STORE_ATTEMPTS; attempt++) { + if (attempt % YIELD_EVERY == 0) { + // The spin cannot help against a producer descheduled between claiming its slot and + // filling it; giving the core up can. + Thread.yield(); + } + if (queue.offer(element)) { + return true; + } + } + return false; + } + + @Override + Object retrieve() { + return queue.poll(); + } +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java new file mode 100644 index 00000000000..95b8d087f27 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java @@ -0,0 +1,43 @@ +package datadog.common.queue; + +import org.jctools.queues.MessagePassingQueue; + +/** + * A {@link WorkQueue} over a JCTools MPSC array queue: many producers, one consumer, no per-element + * node. The preferred backing. + * + *

Storage only. The bound and the reserve-before-construct guarantee both live in {@link + * BaseWorkQueue}, which spends a place before it calls any producer, so by the time an element + * reaches {@link #store} the ring is known to have room for it. + * + *

That the ring could have enforced its own bound, inside a CAS it was performing anyway, is the + * cost of this arrangement — see {@link BaseWorkQueue} for what it buys. What it avoids is holding + * a ring position open across a caller-controlled gap: the ring reports a claimed-but-unfilled + * position as empty, so a reservation that held one would stall the consumer, and would need a + * placeholder object per reservation for the consumer to tell an abandoned position from a pending + * one. + */ +final class MpscWorkQueue extends BaseWorkQueue { + + private final MessagePassingQueue queue; + + MpscWorkQueue(int requestedCapacity) { + this(Queues.mpscArrayQueue(requestedCapacity)); + } + + /** Takes the queue already built, so the bound can be the capacity it actually rounded up to. */ + private MpscWorkQueue(MessagePassingQueue queue) { + super(queue.capacity()); + this.queue = queue; + } + + @Override + boolean store(Object element) { + return queue.offer(element); + } + + @Override + Object retrieve() { + return queue.poll(); + } +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/Producer.java b/utils/queue-utils/src/main/java/datadog/common/queue/Producer.java new file mode 100644 index 00000000000..8b384c7daea --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/Producer.java @@ -0,0 +1,22 @@ +package datadog.common.queue; + +import datadog.trace.api.function.Strategy; + +/** + * Produces an element for admission into a {@link WorkQueue}. + * + *

A producer is only invoked once a place has been claimed, so it is never called for an element + * that will be rejected. Implementations must be non-capturing — a {@code static final} constant of + * the concrete type, or a lambda that closes over nothing — which is what {@link Strategy} marks. + * + *

That is not a preference, it is the whole reason this form exists. A capturing lambda + * allocates once per call, and so does a {@link Reservation}; the reservation is straight-line code + * that keeps whatever the call site had hoisted and needs no context parameters. So a producer that + * captures is strictly worse than the reserve form it was meant to improve on. If the state will + * not fit the context parameters, use {@link WorkQueue#tryReserve} rather than closing over it. + */ +@Strategy +@FunctionalInterface +public interface Producer { + T produce(); +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/Queues.java b/utils/queue-utils/src/main/java/datadog/common/queue/Queues.java index 9c3de5fac8a..478b1d4d2bc 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/Queues.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/Queues.java @@ -2,9 +2,11 @@ import datadog.environment.JavaVirtualMachine; import org.jctools.queues.MessagePassingQueue; +import org.jctools.queues.MpmcArrayQueue; import org.jctools.queues.MpscArrayQueue; import org.jctools.queues.SpmcArrayQueue; import org.jctools.queues.SpscArrayQueue; +import org.jctools.queues.varhandle.MpmcVarHandleArrayQueue; import org.jctools.queues.varhandle.MpscVarHandleArrayQueue; import org.jctools.queues.varhandle.SpmcVarHandleArrayQueue; import org.jctools.queues.varhandle.SpscVarHandleArrayQueue; @@ -43,6 +45,28 @@ public static MessagePassingQueue mpscArrayQueue(int requestedCapacity) { return new MpscArrayQueue<>(requestedCapacity); } + /** + * Creates a Multiple Producer, Multiple Consumer (MPMC) array-backed queue. + * + *

{@code offer} is non-linearizable, and deliberately so: it can refuse while another thread + * is midway through publishing to the slot it is looking at, on a queue that is neither full nor + * empty. Measured at roughly 0.24% of offers with four producers and four consumers on a queue of + * eight. A caller that treats a refusal as "full" will therefore drop work it had room for; + * either retry, or hold the bound somewhere the queue cannot lie about -- which is what {@link + * WorkQueues#createMpmcQueue} does. {@code poll} carries no matching hazard: it spins for a + * pending publish rather than reporting empty, so it can be slow where {@code offer} is wrong. + * + * @param requestedCapacity the requested capacity of the queue. Will be rounded to the next power + * of two, and is not permitted to be less than two. + * @return a new {@link MessagePassingQueue} instance suitable for MPMC usage + */ + public static MessagePassingQueue mpmcArrayQueue(int requestedCapacity) { + if (CAN_USE_VARHANDLES) { + return new MpmcVarHandleArrayQueue<>(requestedCapacity); + } + return new MpmcArrayQueue<>(requestedCapacity); + } + /** * Creates a Single Producer, Multiple Consumer (SPMC) array-backed queue. * diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/RejectHandler.java b/utils/queue-utils/src/main/java/datadog/common/queue/RejectHandler.java new file mode 100644 index 00000000000..afab1042290 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/RejectHandler.java @@ -0,0 +1,27 @@ +package datadog.common.queue; + +import datadog.trace.api.function.Strategy; + +/** + * Sees the source elements a batch admission could not take, for a caller that has somewhere to put + * them: a resubmission list, a spill buffer, a per-kind counter. + * + *

A handler rather than a returned collection, so a caller that only wanted the count is charged + * nothing for one it would have thrown away, and a caller that wants the elements chooses where + * they go instead of receiving a list it has to copy out of. The admission side's answer to {@link + * ExceptionHandler}, which does the same thing for a consumer's failures. + * + *

Only refusals reach a handler. An element the producer declined by returning {@code null} was + * the caller's own decision and is not a rejection. The queue cannot hold that line perfectly at + * the boundary, though: a place is claimed before the producer is asked, so once the queue is full + * a handler sees source elements the producer would have declined, indistinguishable from the rest. + * A caller resubmitting what it is handed should apply its own decline rule again. + * + * @see WorkQueue#tryPutBatch(java.util.Collection, Object, BiContextualProducer, RejectHandler) + */ +@Strategy +@FunctionalInterface +public interface RejectHandler { + /** Called on the admitting thread, once per source element that could not be admitted. */ + void onRejected(E element); +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/Reservation.java b/utils/queue-utils/src/main/java/datadog/common/queue/Reservation.java new file mode 100644 index 00000000000..e901ab86bf2 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/Reservation.java @@ -0,0 +1,57 @@ +package datadog.common.queue; + +/** + * A claimed place in a {@link WorkQueue}, for a caller whose work between claiming and filling + * cannot be expressed as a {@link Producer}. + * + *

What is claimed is capacity, never a position: the element joins the queue where it is filled, + * not where it was claimed, so an open reservation holds no place a consumer could be waiting on + * and cannot stall one. A reservation that is neither filled nor closed does leak its capacity, + * quietly and permanently, which is why this is an {@link AutoCloseable} meant for + * try-with-resources. + * + *

A refused claim is a reservation too, rather than a {@code null}, and one that quietly + * discards whatever is filled into it. Nothing about the failed path throws, so the shortest + * correct call site is also the obvious one: + * + *

{@code
+ * try (Reservation place = queue.tryReserve()) {
+ *   place.fill(buildTask());
+ * }
+ * }
+ * + *

Consulting {@link #granted} first is what buys the reserve-first guarantee — skip the build + * and nothing is allocated for a queue that had no room for it: + * + *

{@code
+ * try (Reservation place = queue.tryReserve()) {
+ *   if (place.granted()) {
+ *     place.fill(buildTask());
+ *   }
+ * }
+ * }
+ */ +public interface Reservation extends AutoCloseable { + + /** + * Whether a place was actually claimed. Worth asking before building anything expensive: a + * refused reservation accepts a fill and throws it away, so checking is what turns + * allocate-then-drop into never-allocate. + * + * @return whether a fill will be kept + */ + boolean granted(); + + /** + * Publishes {@code element} into the claimed place. A granted place is already paid for, so this + * cannot be rejected; a refused one discards the element, having already counted the drop. + */ + void fill(T element); + + /** + * Gives the place back if it was never filled, immediately. Filling first makes this a no-op, and + * nothing is ever consumed for a released place. + */ + @Override + void close(); +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java new file mode 100644 index 00000000000..b10fba899dd --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java @@ -0,0 +1,41 @@ +package datadog.common.queue; + +import javax.annotation.Nonnull; + +/** + * The capability to resubmit work after a consumer failure. + * + *

Only obtainable inside {@link RetryStrategy#onFailure}, never from a plain consumer, so + * re-enqueue-after-failure stays visibly distinct from ordinary admission. + */ +public interface RetryQueue { + /** + * Resubmits the failed item. + * + *

The failed item's place was given back when it was consumed, so this claims a place like any + * other admission and can be rejected if the queue filled up behind it. A refusal here is one + * step of the strategy's decision, not its outcome; {@link RetryStrategy#onFailure} returning + * {@code false} is what says the item was finally given up on. This is the overload every + * ordinary strategy wants: it resubmits without allocating the array the varargs form needs. + * + * @param item the failed item, which must not be {@code null}: a resubmission travels wrapped in + * its attempt count, and the wrapper is what the admission path null-checks, so a null here + * is not turned away -- it reaches the next consumer instead + * @return whether the item was resubmitted + */ + boolean retry(@Nonnull T item); + + /** + * Resubmits several items in place of the failed item. + * + *

Each piece claims its own place, so a partition can be admitted only in part, and the return + * value reports whether all of them made it. As with the single-item overload, a refusal is not + * counted here; a strategy that partially resubmits and returns {@code true} is telling the queue + * the remainder was its own to lose. + * + * @param items the replacement items, none of which may be {@code null} + * @return whether every item was resubmitted + */ + @SuppressWarnings("unchecked") + boolean retry(@Nonnull T... items); +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/RetryStrategy.java b/utils/queue-utils/src/main/java/datadog/common/queue/RetryStrategy.java new file mode 100644 index 00000000000..e8da41df964 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/RetryStrategy.java @@ -0,0 +1,21 @@ +package datadog.common.queue; + +import datadog.trace.api.function.Strategy; + +/** + * Decides what happens to an item whose consumer threw. + * + *

Invoked only on failure — a successful consumption needs no callback. The return value reports + * the decision; it does not report whether the item will eventually succeed. Logging and counting + * are the caller's to compose here: this API performs neither. + */ +@Strategy +@FunctionalInterface +public interface RetryStrategy { + /** + * @param attempt how many times this item has been consumed unsuccessfully, including now, so the + * first failure reports {@code 1} + * @return {@code true} if the item was resubmitted, {@code false} if the strategy gave up + */ + boolean onFailure(T item, int attempt, Throwable failure, RetryQueue retryQueue); +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java new file mode 100644 index 00000000000..c3c604bcb3b --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -0,0 +1,379 @@ +package datadog.common.queue; + +import datadog.trace.api.function.Strategy; +import datadog.trace.api.function.StrategyConsumer; +import java.util.Collection; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * A bounded handoff point between producers and a consumer, with admission that never builds an + * element it is going to reject. + * + *

Why to use this instead of a queue and a call site

+ * + * Every failure below was found in this tree, in code that offers into a {@code + * java.util.concurrent} queue by hand. They are not hypothetical, and none of them is anyone's + * carelessness -- each is what the shape of {@code offer} invites. The last column is the honest + * one: this API makes some of them impossible, some merely unlikely, one it only makes explicit, + * and one it does not address at all. + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Hand-rolled admission failures, and what this API does about them
FailureSeen asHere
The refusal is discarded, so a drop is invisibleSeven call sites in {@code RumInjectorMetrics}; two in {@code WafMetricCollector}, which + * tests the return at a dozen sites and drops it at two; {@code ProductChangeCollector}; + * {@code IntegrationsCollector}Not addressed. Refusal is reported only through the return value, so a caller + * that ignores it loses the element as silently as before. A drop counter owned by the + * queue would cover this and is deliberately not in this version -- every other refusal + * here is already reported synchronously, so it would have been a safety net for a + * negligent caller rather than new information, and nothing yet surfaces it. What does + * help is that {@link #tryPutBatch} answers for a whole drain at once, leaving one return + * to ignore where a hand-rolled loop leaves one per element
State is destroyed to build an element that is then refused, losing it permanently{@code WafMetricCollector} does {@code getAndSet(i, 0)} on a counter, then abandons the + * metric if the queue is full -- the delta is gone. {@code CoreMetricCollector} carries a + * comment about avoiding exactly this, so it is knownImpossible if admission is reached through a producer or a {@link Reservation}: the + * place is claimed before the source is consumed, so there is no window in which a caller + * has destroyed something it cannot hand off
The first refusal abandons the rest of the batch, silently{@code WafMetricCollector} returns out of its publish loop on a failed offerImpossible. {@link #tryPutBatch} reports how many it admitted, or hands back the + * elements it refused, so a partial admission is a number rather than a guess
A capacity check that another thread invalidates before the offer{@code CoreMetricCollector}'s {@code remainingCapacity() == 0}; + * {@code FlagEvaluationWriterImpl}'s {@code size() < capacity()}, whose javadoc correctly + * calls itself best-effortImpossible. The check is the claim -- a granted place cannot be taken by a + * racing producer, so there is no check-then-act to lose
The element is built before anyone asks whether there is roomMost sites, since the element is the argument to {@code offer}Unlikely. The producer forms make the cheap order the natural one, but + * {@link #tryPut(Object)} still lets a caller build first, on purpose, for elements that + * already exist
An unbounded queue, so pressure becomes heap{@code ProductChangeCollector} and {@code IntegrationsCollector} are + * {@code new LinkedBlockingQueue<>()}Only made explicit. A bound is required unless a caller names + * {@code WorkQueues.createUnboundedMpmcQueue}, which is a decision rather than a default
+ * + *

Worth reading {@code FlagEvaluationWriterImpl} as the counter-example: it is bounded, it + * counts its drops, it surfaces the count as a metric, and it documents its own race honestly. It + * is proof the discipline is achievable by hand -- at the cost of that team working it out and + * writing it down themselves, which is the cost this module exists to pay once. Note that its drop + * counter is the one part of it this API does not replace: counting and publishing a refusal stays + * the caller's, which is the same division {@code FlagEvaluationWriterImpl} already chose. + * + *

Capacity is fixed by construction. A queue never grows in response to fullness: full means + * refuse. Admission claims a place before invoking any producer, so a rejected element is never + * constructed at all — the guarantee that makes it safe to hand this a producer that allocates + * heavily, since the allocation cannot happen on the path where it would be wasted. + * + *

What is claimed is capacity, never a position, so a producer never holds up a consumer. It + * does hold a place other producers could have used, though: work that blocks, or that takes + * appreciably longer than an allocation, is paid for by everyone else admitting to this queue. + * Producers should build their element and nothing else. + * + *

Prefer the producer forms, and treat {@link #tryReserve} as the fallback. The distinction is + * {@code forEach} against {@code Iterator}: with a producer the queue owns the sequence, claiming + * and building in the order it knows to be safe, and there is no protocol for a caller to get + * wrong. A reservation hands that loop back — the caller must check {@link Reservation#granted}, + * must fill or close, and an abandoned one is capacity nobody can see or reclaim, the same way a + * half-consumed iterator is state its collection cannot account for. Reach for it when the work + * between claiming and filling genuinely will not fold into a callback, and use {@code tryPut} + * everywhere else. + * + *

Consumption is synchronous and happens in the caller's frame; the boolean returned by the + * {@code process} methods reports whether there was an item to work on, which is the signal a drain + * loop needs, and says nothing about whether the consumer succeeded. A consumer that throws throws + * out of {@code process} — the queue takes no view on failure it was not given one for, and never + * logs. Say what should happen instead by calling {@link #processOrRetry} with a {@link + * RetryStrategy}, or {@link #processOrHandle} with an {@link ExceptionHandler} when the answer is + * only ever to record it and move on. Those are separate names rather than overloads because a + * lambda or method reference cannot always tell two same-arity callbacks apart. + * + *

Nulls carry meaning in three places and are a bug everywhere else. A context may be + * null: the queue carries it to a producer or consumer and never looks at it, so an absent one is + * the caller's business. An optional {@link RejectHandler} may be null, which says exactly what the + * overload without it says. And a producer's return may be null, which is that producer + * declining the element it was asked to build — the place goes back and nothing is admitted, which + * reads as a refusal to the caller even though nothing was lost. + * + *

Everything else is required. An element is never null, because neither backing can hold + * one: there is no outcome to report, so {@code tryPut} and {@link Reservation#fill fill} throw + * instead of returning, and they throw before claiming a place so that a call with a bug in it + * costs the queue nothing. A null inside a batch throws partway through, abandoning the rest. + * Producers, consumers, retry strategies and exception handlers are required too — a null + * there has no sensible reading, and it surfaces as the thrown {@link NullPointerException} of the + * call that would have used it, with any place already claimed given back first. + */ +public interface WorkQueue { + + /** + * @return whether the element was admitted + * @throws NullPointerException if the element is null, thrown before a place is claimed + */ + boolean tryPut(T element); + + /** + * Admits an element, constructing it only once a slot is reserved. + * + *

A producer returning {@code null} declines: its place goes back, nothing is admitted, and + * nothing was lost. The {@code false} that comes back is the same {@code false} a full queue + * gives, so a caller whose producer declines conditionally -- a drain that skips a counter + * sitting at zero, say -- cannot tell "nothing to send" from "no room" from this return alone. + * {@link #tryPutBatch(Collection, Object, BiContextualProducer)} separates the two, by reporting + * how many of the source elements it admitted. + * + * @return whether the element was admitted, which is {@code false} both for a refusal and for a + * producer that declined + */ + @StrategyConsumer + boolean tryPut(@Strategy Producer producer); + + /** + * Admits an element derived from {@code context}, constructing it only once a slot is reserved. + * + * @return whether the element was admitted; a producer returning {@code null} declines, and reads + * as a refusal here -- see {@link #tryPut(Producer)} + */ + @StrategyConsumer + boolean tryPut(C context, @Strategy ContextualProducer producer); + + /** + * Admits an element derived from two contexts, constructing it only once a slot is reserved. + * + * @return whether the element was admitted; a producer returning {@code null} declines, and reads + * as a refusal here -- see {@link #tryPut(Producer)} + * @see BiContextualProducer + */ + @StrategyConsumer + boolean tryPut( + C1 first, + C2 second, + @Strategy BiContextualProducer 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); + + /** + * Admits an element per source element, constructing each only once a slot is reserved for it. + * The queue owns the walk, so the producer is asked only for elements there is already room for, + * and a caller batching work this way never holds capacity of its own. + * + *

The producer may decline a source element by returning {@code null}. That is an explicit + * decision by the caller rather than a loss, so a declined element does not count as admitted; + * the place claimed for it is simply given back. + * + *

A count rather than the refused source elements, because the count is the number a caller + * can act on and the elements are not. A caller that knows how many it meant to admit gets the + * exact shortfall by subtraction, with its own declines excluded from both sides. The refused + * elements cannot be that precise: a place is claimed before the producer is asked, so a full + * queue cannot tell a genuine refusal from an element the producer would have declined anyway, + * and hands back some of each. + * + *

{@code context} is the one value the whole batch shares and a source element cannot recover + * on its own — a schema, a clock reading, a per-batch buffer. It is read once here rather than + * per element, which is the hoist the single-element form spells out in {@link + * BiContextualProducer}. + * + *

{@link Collection} rather than {@link Iterable} because admission runs while there is room, + * and a queue with a live consumer keeps making room: a source with no end would not terminate. + * + *

Reach for this only when the walk exists to admit and nothing else. The queue stops asking + * once it runs out of room, so the producer is the only per-source-element hook a caller gets and + * it is reached only for elements there was room for. A loop that also carries something across + * its iterations — a count of what it considered, a flag OR-ed over the whole source, a decision + * about the batch as a whole — needs every source element regardless of admission, and hands back + * more per element than a producer can return. Such a caller keeps its own loop and admits one + * element at a time; that is not a shortcoming of the loop. + * + * @return how many elements were admitted + * @see BiContextualProducer + */ + @StrategyConsumer + int tryPutBatch( + Collection source, + C context, + @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, + @Strategy BiContextualProducer producer, + @Strategy RejectHandler onRejected); + + /** + * Claims a place without supplying its element, for a caller whose work between claiming and + * filling cannot be expressed as a {@link Producer}. + * + *

What is reserved is capacity, not a position — {@link Reservation#fill} cannot be rejected, + * and the element joins the queue where it is filled. Nothing is held open that a consumer could + * be waiting on, so a thread may safely reserve and consume, but a reservation that is never + * filled or closed leaks its capacity for good. Use try-with-resources. + * + *

Never {@code null}: a refusal comes back as a reservation that reports {@link + * Reservation#granted} as {@code false} and discards anything filled into it. Nothing on the + * refused path throws, so the try-with-resources is always safe; checking {@code granted} is what + * lets the caller skip building an element the queue had no room for. + * + * @return the claimed capacity, or a refused reservation if there was none to claim + */ + Reservation tryReserve(); + + /** + * Consumes one item, if there is one. A throwing consumer propagates. + * + * @return whether there was an item to consume + */ + boolean process(Consumer consumer); + + /** + * Consumes one item, if there is one, handing a throwing consumer's failure to {@code + * retryStrategy} rather than propagating it. + * + * @return whether there was an item to consume + */ + boolean processOrRetry(Consumer consumer, @Strategy RetryStrategy retryStrategy); + + /** + * Consumes one item, if there is one, handing a throwing consumer's failure to {@code + * exceptionHandler} rather than propagating it. The item is dropped. + * + * @return whether there was an item to consume + */ + boolean processOrHandle( + Consumer consumer, @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, 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, + BiConsumer consumer, + @Strategy RetryStrategy retryStrategy); + + /** + * Consumes one item, if there is one, handing a throwing consumer's failure to {@code + * exceptionHandler} rather than propagating it. The item is dropped. + * + * @return whether there was an item to consume + */ + boolean processOrHandle( + C context, + BiConsumer consumer, + @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, 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, 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(); + + /** + * {@link #close() Closes} and then {@link #clear() clears} — the flag before the discard, so a + * producer that has not started yet cannot begin. + * + *

Not atomic, and not made atomic by being one call. A producer already past the closed check, + * or an in-flight retry lease, can still store its element after the discard has run, and that + * element then sits in a queue nothing will drain again. Ordering the flag first bounds the + * survivors to those already in flight rather than eliminating them. + * + *

A caller that needs the queue provably empty has to quiesce its producers first and shut + * down after. The queue cannot do that half on the caller's behalf: it knows when it is closed, + * but not who is still holding a place or how long they mean to hold it. + */ + void shutdown(); +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java new file mode 100644 index 00000000000..bda2d207f54 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java @@ -0,0 +1,68 @@ +package datadog.common.queue; + +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * Factory methods for {@link WorkQueue} buffers: bounded handoff points that count what they drop + * and never build an element they are going to reject. + * + *

Distinct from {@link Queues}, which hands back a raw JCTools queue for the caller to drive + * itself. A buffer created here owns its backing — which implementation it is stays an + * implementation detail, so a call site can be re-backed without changing. + */ +public final class WorkQueues { + + private WorkQueues() {} + + /** + * Creates a bounded Multiple Producer, Single Consumer buffer backed by an MPSC array queue. + * + *

The preferred backing: no per-element node, constant-time {@link WorkQueue#size()}, and + * admission that claims a slot before invoking a producer, so an element that will not fit is + * never built. + * + *

Single Consumer is a requirement, not a characteristic. Producers may be any number of + * threads, but every call that takes elements out -- any {@code process}, {@code processOrRetry} + * or {@code processOrHandle} overload, plus {@link WorkQueue#clear} and {@link + * WorkQueue#shutdown} -- must come from one thread. A second consumer is not rejected and does + * not throw: the two can spin inside the ring's gap-wait indefinitely, which presents as a hang + * rather than a failure. Use {@link #createMpmcQueue} where more than one thread drains. + * + * @param requestedCapacity the bound. Will be rounded to the next power of two. + */ + public static WorkQueue createMpscQueue(int requestedCapacity) { + return new MpscWorkQueue<>(requestedCapacity); + } + + /** + * Creates a bounded Multiple Producer, Multiple Consumer buffer backed by an MPMC array queue. + * + *

For call sites that need several consumers. No per-element node, so it costs an admission + * and a drain about what the MPSC ring does; prefer {@link #createMpscQueue} anyway where a + * single consumer is possible, because the MPSC ring is cheaper still and does not have to ride + * out the MPMC ring's transient refusals — see {@link MpmcWorkQueue} for what those are and why + * claiming a place first makes them harmless. + * + * @param requestedCapacity the bound. Will be rounded to the next power of two, and raised to two + * if it is less than that. + */ + public static WorkQueue createMpmcQueue(int requestedCapacity) { + return MpmcWorkQueue.bounded(requestedCapacity); + } + + /** + * Creates an unbounded Multiple Producer, Multiple Consumer buffer backed by a {@link + * ConcurrentLinkedQueue}. + * + *

Unbounded means admission never rejects, so the only element this can lose is one a retry + * strategy gives up on. Intended as a migration step for call sites that are unbounded today: + * adopt the interface here, then pick a bound and move to {@link #createMpscQueue}. + * + *

Linked rather than array-backed because there is no capacity to size an array from. JCTools' + * unbounded MPMC queue would avoid the per-element node, but exists only in an {@code Unsafe} + * form, which {@link Queues} deliberately steps away from on Java 25 and later. + */ + public static WorkQueue createUnboundedMpmcQueue() { + return MpmcWorkQueue.unbounded(); + } +} diff --git a/utils/queue-utils/src/test/java/datadog/common/queue/MpscWorkQueueStressTest.java b/utils/queue-utils/src/test/java/datadog/common/queue/MpscWorkQueueStressTest.java new file mode 100644 index 00000000000..0171b5ec56a --- /dev/null +++ b/utils/queue-utils/src/test/java/datadog/common/queue/MpscWorkQueueStressTest.java @@ -0,0 +1,266 @@ +package datadog.common.queue; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicIntegerArray; +import org.junit.jupiter.api.Test; + +/** + * Contention tests for the MPSC backing, where the admission contract actually has to hold: many + * producers claiming slots against a single consumer freeing them. + * + *

What is being checked is conservation. Every element a producer was told it admitted must + * reach the consumer exactly once, and every admission must be told one thing or the other — so + * admitted plus refused, as the producers themselves saw it, accounts for everything offered, with + * nothing lost, duplicated or invented in between. The producers keep that tally, because the + * return of {@code tryPut} is the only report a refusal gets. + */ +class MpscWorkQueueStressTest { + + private static final int PRODUCERS = 8; + private static final int PER_PRODUCER = 20_000; + private static final int TOTAL = PRODUCERS * PER_PRODUCER; + private static final int CAPACITY = 128; + private static final long TIMEOUT_SECONDS = 60; + + @Test + void conservesEveryElementUnderContention() throws Exception { + WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); + AtomicIntegerArray timesSeen = new AtomicIntegerArray(TOTAL); + AtomicInteger admitted = new AtomicInteger(); + AtomicInteger refused = new AtomicInteger(); + AtomicInteger consumed = new AtomicInteger(); + + CountDownLatch start = new CountDownLatch(1); + CountDownLatch producersDone = new CountDownLatch(PRODUCERS); + + for (int p = 0; p < PRODUCERS; p++) { + final int producer = p; + Thread thread = + new Thread( + () -> { + await(start); + try { + for (int i = 0; i < PER_PRODUCER; i++) { + int value = producer * PER_PRODUCER + i; + if (queue.tryPut(value)) { + admitted.incrementAndGet(); + } else { + refused.incrementAndGet(); + } + } + } finally { + producersDone.countDown(); + } + }, + "producer-" + p); + thread.setDaemon(true); + thread.start(); + } + + AtomicBoolean consumerFailed = new AtomicBoolean(); + Thread consumer = + new Thread( + () -> { + boolean producersFinished = false; + while (true) { + boolean hadWork = queue.process(value -> timesSeen.incrementAndGet(value)); + if (hadWork) { + consumed.incrementAndGet(); + } else if (producersFinished) { + return; + } else { + producersFinished = producersDone.getCount() == 0; + Thread.yield(); + } + } + }, + "consumer"); + consumer.setDaemon(true); + consumer.setUncaughtExceptionHandler((t, e) -> consumerFailed.set(true)); + consumer.start(); + + start.countDown(); + assertTrue( + producersDone.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), "producers did not finish in time"); + consumer.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS)); + assertFalse(consumer.isAlive(), "consumer did not finish in time"); + assertFalse(consumerFailed.get(), "consumer thread threw"); + + assertEquals( + admitted.get(), consumed.get(), "every admitted element reaches the consumer once"); + assertEquals( + TOTAL - admitted.get(), refused.get(), "every rejection was reported to its producer"); + assertEquals(0, queue.size()); + + for (int value = 0; value < TOTAL; value++) { + int seen = timesSeen.get(value); + assertTrue(seen <= 1, "element " + value + " was consumed " + seen + " times"); + } + } + + /** + * The reserve-before-construct guarantee under contention: producers race for a capacity that is + * never freed, so no producer may ever run. + */ + @Test + void neverInvokesProducerWhileFull() throws Exception { + WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); + while (queue.tryPut(0)) { + // fill it, and leave it full — nothing consumes + } + AtomicInteger produced = new AtomicInteger(); + AtomicInteger refusedAfterFull = new AtomicInteger(); + AtomicInteger admittedAfterFull = new AtomicInteger(); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(PRODUCERS); + + for (int p = 0; p < PRODUCERS; p++) { + Thread thread = + new Thread( + () -> { + await(start); + try { + for (int i = 0; i < PER_PRODUCER; i++) { + boolean landed = + queue.tryPut( + produced, + counter -> { + counter.incrementAndGet(); + return 1; + }); + if (landed) { + admittedAfterFull.incrementAndGet(); + } else { + refusedAfterFull.incrementAndGet(); + } + } + } finally { + done.countDown(); + } + }, + "producer-" + p); + thread.setDaemon(true); + thread.start(); + } + + start.countDown(); + assertTrue(done.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), "producers did not finish in time"); + + assertEquals(0, admittedAfterFull.get(), "a full queue admits nothing"); + assertEquals(0, produced.get(), "no element may be built for a slot that was never claimed"); + assertEquals( + (long) PRODUCERS * PER_PRODUCER, + refusedAfterFull.get(), + "every rejected admission was reported to its producer"); + } + + /** + * Reservations mixed into ordinary admission under contention: the consumer has to tell a place + * that is still being filled from an element that is ready, and from a place that was abandoned, + * without losing or duplicating anything behind it. + */ + @Test + void conservesElementsWhenProducersReserve() throws Exception { + WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); + AtomicIntegerArray timesSeen = new AtomicIntegerArray(TOTAL); + AtomicInteger admitted = new AtomicInteger(); + AtomicInteger consumed = new AtomicInteger(); + + CountDownLatch start = new CountDownLatch(1); + CountDownLatch producersDone = new CountDownLatch(PRODUCERS); + + for (int p = 0; p < PRODUCERS; p++) { + final int producer = p; + Thread thread = + new Thread( + () -> { + await(start); + try { + for (int i = 0; i < PER_PRODUCER; i++) { + int value = producer * PER_PRODUCER + i; + switch (i % 3) { + case 0: + if (queue.tryPut(value)) { + admitted.incrementAndGet(); + } + break; + case 1: + try (Reservation place = queue.tryReserve()) { + if (place.granted()) { + place.fill(value); + admitted.incrementAndGet(); + } + } + break; + default: + // claimed and then abandoned: the consumer must skip it + try (Reservation place = queue.tryReserve()) { + // no fill + } + break; + } + } + } finally { + producersDone.countDown(); + } + }, + "producer-" + p); + thread.setDaemon(true); + thread.start(); + } + + AtomicBoolean consumerFailed = new AtomicBoolean(); + Thread consumer = + new Thread( + () -> { + boolean producersFinished = false; + while (true) { + boolean hadWork = queue.process(value -> timesSeen.incrementAndGet(value)); + if (hadWork) { + consumed.incrementAndGet(); + } else if (producersFinished) { + return; + } else { + producersFinished = producersDone.getCount() == 0; + Thread.yield(); + } + } + }, + "consumer"); + consumer.setDaemon(true); + consumer.setUncaughtExceptionHandler((t, e) -> consumerFailed.set(true)); + consumer.start(); + + start.countDown(); + assertTrue( + producersDone.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), "producers did not finish in time"); + consumer.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS)); + assertFalse(consumer.isAlive(), "consumer did not finish in time"); + assertFalse(consumerFailed.get(), "consumer thread threw"); + + assertEquals( + admitted.get(), consumed.get(), "every filled place reaches the consumer exactly once"); + assertEquals(0, queue.size(), "no abandoned place is left holding capacity"); + + for (int value = 0; value < TOTAL; value++) { + int seen = timesSeen.get(value); + assertTrue(seen <= 1, "element " + value + " was consumed " + seen + " times"); + } + } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } +} diff --git a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java new file mode 100644 index 00000000000..617ec1401a4 --- /dev/null +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -0,0 +1,1199 @@ +package datadog.common.queue; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.IntFunction; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** The behaviour every backing must share, exercised against each of them. */ +class WorkQueueContractTest { + + private static final int CAPACITY = 4; + + static Stream boundedQueues() { + return Stream.of( + Arguments.of("mpsc", (IntFunction>) WorkQueues::createMpscQueue), + Arguments.of("mpmc", (IntFunction>) WorkQueues::createMpmcQueue)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void admitsUpToCapacityThenRefuses(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i)); + } + assertEquals(CAPACITY, queue.size()); + assertFalse(queue.tryPut("overflow")); + assertEquals(CAPACITY, queue.size()); + } + + /** The point of the whole API: a rejected element is never built. */ + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void doesNotInvokeProducerWhenFull(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i)); + } + AtomicBoolean produced = new AtomicBoolean(); + assertFalse( + queue.tryPut( + produced, + flag -> { + flag.set(true); + return "built"; + })); + assertFalse(produced.get(), "producer ran for an element that could not be admitted"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void invokesProducerWhenThereIsRoom(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + assertTrue(queue.tryPut("ctx", context -> context + "-built")); + List consumed = consumeAll(queue); + assertEquals(Arrays.asList("ctx-built"), consumed); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void batchAdmissionReportsRejectedElements(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + Collection rejected = queue.tryPutBatch("a", "b", "c", "d", "e", "f"); + assertEquals(Arrays.asList("e", "f"), new ArrayList<>(rejected)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void collectionAdmissionReportsRejectedElements( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + Collection rejected = queue.tryPutBatch(Arrays.asList("a", "b", "c", "d", "e", "f")); + assertEquals(Arrays.asList("e", "f"), new ArrayList<>(rejected)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void transformingBatchAdmissionAppliesTheContextToEverySourceElement( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + int admitted = + queue.tryPutBatch(Arrays.asList(1, 2, 3), "x", (source, suffix) -> source + suffix); + assertEquals(3, admitted); + assertEquals(Arrays.asList("1x", "2x", "3x"), consumeAll(queue)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aDeclinedSourceElementIsNotAdmitted(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + // Every other element declined. Returning null is the caller's own decision, so it does not + // count against the admitted total: the caller already knows it declined. + int admitted = + queue.tryPutBatch( + Arrays.asList(1, 2, 3, 4, 5, 6), + "x", + (source, suffix) -> source % 2 == 0 ? null : source + suffix); + assertEquals(3, admitted); + assertEquals(Arrays.asList("1x", "3x", "5x"), consumeAll(queue)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void decliningLeavesTheClaimedPlaceAvailableToTheRestOfTheBatch( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + // Nearly twice capacity in source elements, the even ones declined: the place claimed for a + // declined element has to go back, or the batch would run out of room after CAPACITY source + // elements rather than after CAPACITY admitted ones. + int admitted = + queue.tryPutBatch( + Arrays.asList(1, 2, 3, 4, 5, 6, 7), + "x", + (source, suffix) -> source % 2 == 0 ? null : source + suffix); + assertEquals(CAPACITY, admitted); + assertEquals(Arrays.asList("1x", "3x", "5x", "7x"), consumeAll(queue)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void theShortfallIsExactWhenTheCallerKnowsWhatItMeantToAdmit( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + List asked = new ArrayList<>(); + int intended = 6; + int admitted = + queue.tryPutBatch( + Arrays.asList(1, 2, 3, 4, 5, 6), + "x", + (source, suffix) -> { + asked.add(source); + return source + suffix; + }); + assertEquals(CAPACITY, admitted); + // The whole point of the count: a caller that declined nothing gets its loss by subtraction. + assertEquals(2, intended - admitted); + // And the producer was only ever asked about elements there was already room for. + assertEquals(Arrays.asList(1, 2, 3, 4), asked); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aSourceElementTheProducerWouldHaveDeclinedIsStillRefusedOnceFull( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + // The odd elements fill the queue exactly, so element 8 never gets a place -- even though the + // producer would have declined it. The place is claimed before the producer is asked, so the + // queue cannot know that, and reports what is true from where it stands: it could not ask. + // This is why the reject handler is approximate for a declining producer and the shortfall + // by subtraction is not. + List refused = new ArrayList<>(); + int admitted = + queue.tryPutBatch( + Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8), + "x", + (source, suffix) -> source % 2 == 0 ? null : source + suffix, + refused::add); + assertEquals(CAPACITY, admitted); + assertEquals(Arrays.asList("1x", "3x", "5x", "7x"), consumeAll(queue)); + assertEquals( + Arrays.asList(8), + refused, + "element 8 was refused for want of a place, though it would have been declined"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void transformingBatchAdmissionAdmitsNothingOnceClosed( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.close(); + AtomicBoolean asked = new AtomicBoolean(); + int admitted = + queue.tryPutBatch( + Arrays.asList(1, 2), + "x", + (source, suffix) -> { + asked.set(true); + return source + suffix; + }); + assertEquals(0, admitted); + assertFalse(asked.get(), "a closed queue must not ask the producer for anything"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aRejectHandlerSeesEverySourceElementThatCouldNotBeAdmitted( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + List rejected = new ArrayList<>(); + int admitted = + queue.tryPutBatch( + Arrays.asList(1, 2, 3, 4, 5, 6), + "x", + (source, suffix) -> source + suffix, + rejected::add); + assertEquals(CAPACITY, admitted); + assertEquals(Arrays.asList(5, 6), rejected); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aRejectHandlerDoesNotSeeElementsTheProducerDeclined( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + List rejected = new ArrayList<>(); + // Six source elements, three declined, three admitted -- the queue never fills, so nothing was + // refused and the handler is never called. A decline is not a rejection. + int admitted = + queue.tryPutBatch( + Arrays.asList(1, 2, 3, 4, 5, 6), + "x", + (source, suffix) -> source % 2 == 0 ? null : source + suffix, + rejected::add); + assertEquals(3, admitted); + assertTrue(rejected.isEmpty(), "a declined element is the caller's own decision"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aThrowingTransformGivesBackItsPlaceAndPropagates( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + List source = Arrays.asList(1, 2, 3); + assertThrows( + IllegalStateException.class, + () -> + queue.tryPutBatch( + source, + "x", + (element, suffix) -> { + if (element == 2) { + throw new IllegalStateException("boom"); + } + return element + suffix; + })); + // The place claimed for the failed element went back, so the queue still holds capacity for + // three more admissions beyond the one that succeeded. + assertEquals(1, queue.size()); + assertTrue(queue.tryPutBatch("a", "b", "c").isEmpty()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void exceptionHandlerSeesTheFailureAndTheItemIsDropped( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPut("a"); + + List seen = new ArrayList<>(); + assertTrue( + queue.processOrHandle( + item -> { + throw new IllegalStateException("boom"); + }, + (item, failure) -> seen.add(item + ":" + failure.getMessage()))); + + assertEquals(Arrays.asList("a:boom"), seen, "the handler is told which item died"); + assertEquals(0, queue.size()); + assertFalse(queue.process(item -> fail("nothing should be left"))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void exceptionHandlerIsNotCalledWhenTheConsumerSucceeds( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPut("a"); + + List consumed = new ArrayList<>(); + assertTrue( + queue.processOrHandle( + consumed::add, + (item, failure) -> fail("handler ran for a consumer that did not throw"))); + + assertEquals(Arrays.asList("a"), consumed); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void processReportsWhetherThereWasWork(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + assertFalse(queue.process(item -> {}), "empty queue has no work"); + queue.tryPut("a"); + assertTrue(queue.process(item -> {})); + assertFalse(queue.process(item -> {})); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void processPropagatesAConsumerFailureWhenGivenNoStrategy( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPut("a"); + + IllegalStateException thrown = + assertThrows( + IllegalStateException.class, + () -> + queue.process( + item -> { + throw new IllegalStateException("boom"); + }), + "without a strategy the queue takes no view on failure"); + + assertEquals("boom", thrown.getMessage()); + assertEquals(0, queue.size(), "the item was still consumed off the queue"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void processReportsWorkEvenWhenTheStrategyGivesUp( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPut("a"); + RetryStrategy giveUp = (item, attempt, failure, retryQueue) -> false; + assertTrue( + queue.processOrRetry( + item -> { + throw new IllegalStateException("boom"); + }, + giveUp), + "the return value reports work found, not consumer success"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void retriesUntilTheStrategyGivesUp(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPut("a"); + AtomicInteger attempts = new AtomicInteger(); + List reported = new ArrayList<>(); + + RetryStrategy strategy = + (item, attempt, failure, retryQueue) -> { + reported.add(attempt); + return attempt < 2 && retryQueue.retry(item); + }; + + while (queue.processOrRetry( + item -> { + attempts.incrementAndGet(); + throw new IllegalStateException("boom"); + }, + strategy)) { + // drain until the strategy stops resubmitting + } + + assertEquals(2, attempts.get(), "consumed twice: original plus one retry"); + assertEquals(Arrays.asList(1, 2), reported, "attempt counts survive re-admission"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void maxRetriesBoundsResubmission(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPut("a"); + AtomicInteger attempts = new AtomicInteger(); + RetryStrategy strategy = new MaxRetries<>(3); + + while (queue.processOrRetry( + item -> { + attempts.incrementAndGet(); + throw new IllegalStateException("boom"); + }, + strategy)) { + // drain + } + + assertEquals(4, attempts.get(), "three retries on top of the original consumption"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void closeStopsAdmissionButKeepsBacklog(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPut("a"); + queue.close(); + + assertTrue(queue.isClosed()); + assertFalse(queue.tryPut("b")); + assertEquals(1, queue.size(), "already-admitted work survives so a consumer can finish"); + assertEquals(Arrays.asList("a"), consumeAll(queue)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void clearDiscardsContentsButLeavesAdmissionOpen( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPutBatch("a", "b"); + queue.clear(); + + assertEquals(0, queue.size()); + assertFalse(queue.isClosed()); + assertTrue(queue.tryPut("c"), "clear does not close"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void shutdownClosesAndDiscards(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.tryPutBatch("a", "b"); + queue.shutdown(); + + assertEquals(0, queue.size()); + assertTrue(queue.isClosed()); + assertFalse(queue.tryPut("c")); + } + + @org.junit.jupiter.api.Test + void aBoundedMpmcQueueRoundsItsCapacityUpToTheRing() { + // The bound is the ring's own capacity, not the number asked for, so the counter and the ring + // can never disagree about what full means. A caller asking for 100 gets 128. + WorkQueue queue = WorkQueues.createMpmcQueue(100); + for (int i = 0; i < 128; i++) { + assertTrue(queue.tryPut("e" + i), "place " + i + " should have been there"); + } + assertFalse(queue.tryPut("overflow")); + assertEquals(128, queue.size()); + } + + @org.junit.jupiter.api.Test + void aBoundedMpmcQueueTooSmallForTheRingIsRaisedRatherThanRefused() { + // JCTools will not build a ring of one. Rounding up is the established answer to a capacity + // the ring cannot honour, and throwing here would only surprise a caller who asked for less. + WorkQueue queue = WorkQueues.createMpmcQueue(1); + assertTrue(queue.tryPut("a")); + assertTrue(queue.tryPut("b")); + assertFalse(queue.tryPut("c")); + } + + @org.junit.jupiter.api.Test + void severalProducersAndConsumersOnAnArrayRingLoseNothingAndLeakNoPlace() + throws InterruptedException { + // The MPMC ring refuses an offer while another thread is midway through publishing to the slot + // in question -- on a queue that is neither full nor empty. Admission claims a place before it + // stores, so such a refusal can only mean "not yet" and the backing retries. If that reasoning + // is wrong, it is wrong here: elements go missing without any producer being told, or their + // places never come back. The drain side is not riding out the same window -- 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 retry had given up 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")); + } + + @org.junit.jupiter.api.Test + void unboundedQueueNeverRejects() { + WorkQueue queue = WorkQueues.createUnboundedMpmcQueue(); + for (int i = 0; i < 1000; i++) { + assertTrue(queue.tryPut("e" + i)); + } + assertEquals(1000, queue.size()); + } + + @org.junit.jupiter.api.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 on every backing; only the array backing also holds position. + + @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(); + + // The array backing reclaims the slot as the consumer passes over it rather than at close, so + // the capacity is back once the queue has been drained, not necessarily the instant it is + // abandoned. What both backings promise is that 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()); + } + + /** The array backing claims a slot, so the element keeps the position it was reserved at. */ + @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)); + } + + @org.junit.jupiter.api.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")); + } + + /** + * A backing that claims to be unable to store anything, so the one outcome no real backing + * produces on demand -- a refusal of an element a place was already claimed for -- can be tested + * at all. {@link MpmcWorkQueue} reaches it by exhausting its retry bound, which a test cannot + * provoke reliably. + */ + private static final class RefusingWorkQueue extends BaseWorkQueue { + RefusingWorkQueue(int capacity) { + super(capacity); + } + + @Override + boolean store(Object element) { + return false; + } + + @Override + Object retrieve() { + return null; + } + } + + @org.junit.jupiter.api.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"); + } + + @org.junit.jupiter.api.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"); + } + + private static List consumeAll(WorkQueue queue) { + List consumed = new ArrayList<>(); + while (queue.process(consumed::add)) { + // drain + } + return consumed; + } +}