Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
6fdbe96
Add Queue<T> admission and consumption API
dougqh Aug 27, 2026
76eeeb1
Add MPSC and linked-queue backings behind Queue<T>
dougqh Aug 27, 2026
cbb32c3
Prefix Queue factory methods with create
dougqh Aug 27, 2026
c1ce00a
Rename Queue to WorkQueue and split its factories from Queues
dougqh Aug 27, 2026
0f009cf
Drop BatchProducer and put() until SCA needs them
dougqh Aug 27, 2026
edbf5a5
Add a single-element RetryQueue.retry overload
dougqh Aug 27, 2026
09dcab0
Let a consumer failure propagate when no RetryStrategy is given
dougqh Aug 27, 2026
f3c1a18
Add tryReserve as an escape hatch for callers that cannot use a Producer
dougqh Aug 27, 2026
8fd67a0
Let the linked backing reserve capacity without holding a position
dougqh Aug 27, 2026
454c309
Bound the linked backing with a permit counter
dougqh Aug 27, 2026
7f627fd
Describe linked-backing reservations, which are no longer unsupported
dougqh Aug 27, 2026
061ce46
Add a batched process that takes an item limit
dougqh Aug 27, 2026
19d8d02
Let a producer take a second context
dougqh Aug 27, 2026
23a7b1f
Bound every backing with the same permit counter
dougqh Aug 27, 2026
a664754
Answer a refused claim with a reservation instead of null
dougqh Aug 27, 2026
a9f6c43
Say which admission form to reach for, and stop describing a stall
dougqh Aug 27, 2026
d47180b
Mark the producer forms as strategies
dougqh Aug 27, 2026
2741689
Answer the review on the admission and consumption edges
dougqh Aug 27, 2026
e3594a5
Name the failure-handling forms apart from plain process
dougqh Aug 27, 2026
833ca7f
Benchmark admission against both backings
dougqh Aug 27, 2026
adabbda
Build a refusal instead of sharing one
dougqh Aug 27, 2026
ed38899
Hand the reservation its queue instead of hiding it
dougqh Aug 28, 2026
d155316
Let the queue walk a batch and transform as it goes
dougqh Aug 28, 2026
e5cc78b
Return how many a batch admitted rather than which were refused
dougqh Aug 28, 2026
7098438
Let a batch caller say where its refusals go
dougqh Aug 28, 2026
58520ce
Say what a null means once, instead of five times differently
dougqh Aug 29, 2026
bc7b7bc
Count a lost item once, not once per step that lost it
dougqh Aug 29, 2026
0497ad5
Stop claiming shutdown is atomic, because it is not
dougqh Aug 29, 2026
9e10f24
Benchmark admission with more than one thread admitting
dougqh Aug 29, 2026
c631f94
Own the drain thread so the contended arm survives a thread-count ove…
dougqh Aug 29, 2026
51fb1c3
Refuse with a load, and let the counter carry the closed state
dougqh Aug 29, 2026
c8cc491
Record what the read bought at the boundary
dougqh Aug 29, 2026
79b3c40
Claim a batch's places in one go, clamped to what is there
dougqh Aug 31, 2026
45a643d
Price the queue against what callers write instead
dougqh Aug 31, 2026
932653d
Back the multi-consumer queue with an array ring
dougqh Aug 31, 2026
74fb505
Measure the third-backing cliff instead of asserting it
dougqh Aug 31, 2026
0b27ed2
Give the place back when a backing refuses a filled element
dougqh Aug 31, 2026
4bba350
Measure what the retry lease actually costs
dougqh Aug 31, 2026
c8500b4
Price the strategy binding, not just the lease
dougqh Aug 31, 2026
95005d0
Finish the strategy binding matrix with the exact-typed field
dougqh Aug 31, 2026
43add18
Say which hand-rolled failures this API deletes
dougqh Aug 31, 2026
888919f
Let the caller's own return value be the record of a refusal
dougqh Sep 1, 2026
adf58b6
Keep size() inside the bound it reports against
dougqh Sep 1, 2026
810fc2a
Say that a retried item may not be null
dougqh Sep 1, 2026
360ba79
Stop claiming the MPMC ring's poll reports a false empty
dougqh Sep 1, 2026
a0cd339
Give MaxRetries the number of retries its name promises
dougqh Sep 1, 2026
5fc2d2d
Point at the JCTools docs instead of re-teaching them
dougqh Sep 1, 2026
77144ca
Say on the producer forms what a null return means
dougqh Sep 1, 2026
663192b
Merge branch 'master' into dougqh/apmlp-1642-queue-api
dougqh Sep 1, 2026
dd9ceb8
Merge branch 'master' into dougqh/apmlp-1642-queue-api
dougqh Oct 7, 2026
f042f49
Remove the MPMC store retry that can never run
dougqh Oct 22, 2026
744707f
Confine a reservation to the thread that reserved it
dougqh Oct 22, 2026
07a0fe1
Mark function-object parameters @Nonnull instead of checking them
dougqh Oct 22, 2026
8ff0cc0
Stop claiming a refused reservation counts its drop
dougqh Oct 22, 2026
77a4e72
Create the retry-count strategy through RetryStrategy.maxRetries
dougqh Oct 22, 2026
1a50f60
Tighten queue Javadoc per review
dougqh Oct 22, 2026
67261e1
State the AdmissionBenchmark question the right way round
dougqh Oct 22, 2026
392bbf9
Stop attributing the steady-arm gap to the drain
dougqh Oct 22, 2026
3edfb22
Point the steady queue's Javadoc at the thread that drains it
dougqh Oct 22, 2026
cfd560f
Stop describing WorkQueues as counting what they drop
dougqh Oct 22, 2026
cf6343f
Describe the batch-claim cap as a limit on withheld capacity
dougqh Oct 22, 2026
8d8995d
Qualify the Producer allocation claim
dougqh Oct 22, 2026
7693aa3
Say which in-flight work can outlive a shutdown
dougqh Oct 22, 2026
08b0283
Reduce the WorkQueue class doc to its contract
dougqh Oct 22, 2026
bf786e9
Price WorkQueue against the ring's own claim-before-build
dougqh Oct 22, 2026
47038d9
Rename ThirdBackingWorkQueue to MegamorphicCallSiteWorkQueue
dougqh Oct 26, 2026
c2ae3e2
Point Queues' class doc at WorkQueues
dougqh Oct 26, 2026
a4bef25
Bring WorkQueueContractTest's reservation comments up to date
dougqh Oct 30, 2026
9fc6cb7
Cover WorkQueue's contextual consumers and batch release paths
dougqh Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions utils/queue-utils/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
package datadog.common.queue;

import java.util.Queue;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.jctools.queues.MessagePassingQueue;
import org.jctools.queues.MpscArrayQueue;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.infra.Blackhole;

/**
* {@link WorkQueue} against the things a caller would otherwise use, including the two it is
* actually replacing. Published because the comparison exists whether or not we run it, and a
* reader who has to measure it themselves is entitled to wonder what we found.
*
* <pre>
* ./gradlew :utils:queue-utils:jmh -Pjmh.includes=AdmissionAlternatives -Pjmh.profilers=gc
* </pre>
*
* <p><b>The decision this table is for.</b> Use {@link MpscArrayQueue} directly when one consumer,
* the ring's own bound, and an element you already hold are the whole requirement -- it is the
* floor here. Reach for {@link WorkQueue} for its guarantees: an element is built, or a destructive
* read made, only once capacity is claimed, through every admission form including batches and
* reservations; refusal is reported rather than swallowed; the queue has a lifecycle; and consumer
* failures can be retried or handled. The numbers below are what those guarantees cost. They are
* not a claim that the API is faster.
*
* <p><b>The baselines are not inventions.</b> {@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.
*
* <p><b>Two halves, because the answer differs.</b> 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.
*
* <p>Results. JDK 17, one machine, {@code @Fork(2)}, {@code -Pjmh.profilers=gc}.
*
* <pre>
* Benchmark threads ns/op B/op
* steadyRawMpscFill 1 22.9 24
* steadyRawMpsc 1 23.8 24
* steadyWorkQueue 1 35.8 24
* steadyArrayBlocking 1 38.0 24
* steadyClqWithCounter 1 42.1 48
* steadyLinkedBlocking 1 45.0 48
* refusedWorkQueue 4 1.5 0
* refusedRawMpscFill 4 2.6 0
* refusedLinkedBlocking 4 139.9 24
* refusedRawMpsc 4 142.0 24
* refusedClqWithCounter 4 149.6 24
* refusedArrayBlocking 4 184.0 24
* </pre>
*
* <p><b>Admitting.</b> The raw ring is the floor at about 23ns, with or without {@code fill}.
* {@link WorkQueue} costs about 13ns more for the permit counter and the producer callback, and no
* more allocation: that is the price of the guarantees above. The incumbents that build first cost
* more than that anyway, and the two linked queues allocate a node per element on top of the
* element itself.
*
* <p><b>Refusing.</b> Every arm that builds before it asks lands between 140 and 185ns with 24
* B/op, and the gap to the claim-first arms is the element, not the queue: the same ring that takes
* 142ns to refuse through {@code offer} refuses in 2.6ns through {@code fill}, which also claims a
* slot before calling its supplier. {@code refusedWorkQueue} is cheaper still, because refusal is a
* load against a counter no refusing thread writes -- see {@code ContendedAdmissionBenchmark}. A
* caller whose element already exists, or is free to build, keeps the ordering of this gap and not
* its size.
*/
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class AdmissionAlternativesBenchmark {

private static final int CAPACITY = 1024;

private static final String ELEMENT = "element";

/**
* What the incumbents build before they ask. Stands in for a {@code WafMetric} or a metric
* sample: not exact, but a real allocation with a stable footprint, so the arm that builds one
* can be told from the arm that does not.
*/
static final class Payload {
final Object a;
final long timestamp;

Payload(Object a, long timestamp) {
this.a = a;
this.timestamp = timestamp;
}
}

/** Only ever called once a place is already claimed, which is the whole difference. */
private static final Producer<Payload> BUILDER = () -> new Payload(ELEMENT, System.nanoTime());

/**
* The same build for the ring's own {@code fill}, which also claims a slot before calling it. It
* must not throw or return null: either would leave a claimed slot the consumer waits on forever.
*/
private static final MessagePassingQueue.Supplier<Payload> RAW_BUILDER =
() -> new Payload(ELEMENT, System.nanoTime());

private MpscArrayQueue<Payload> steadyRaw;
private MessagePassingQueue<Payload> steadyRawFill;
private BlockingQueue<Payload> steadyArrayBlocking;
private BlockingQueue<Payload> steadyLinkedBlocking;
private Queue<Payload> steadyClq;
private AtomicInteger steadyClqCount;
private WorkQueue<Payload> steadyWork;

private MpscArrayQueue<Payload> fullRaw;
private MessagePassingQueue<Payload> fullRawFill;
private BlockingQueue<Payload> fullArrayBlocking;
private BlockingQueue<Payload> fullLinkedBlocking;
private Queue<Payload> fullClq;
private AtomicInteger fullClqCount;
private WorkQueue<Payload> fullWork;

@Setup
public void setUp() {
steadyRaw = new MpscArrayQueue<>(CAPACITY);
steadyRawFill = Queues.mpscArrayQueue(CAPACITY);
steadyArrayBlocking = new ArrayBlockingQueue<>(CAPACITY);
steadyLinkedBlocking = new LinkedBlockingQueue<>(CAPACITY);
steadyClq = new ConcurrentLinkedQueue<>();
steadyClqCount = new AtomicInteger();
steadyWork = WorkQueues.createMpscQueue(CAPACITY);

fullRaw = new MpscArrayQueue<>(16);
fullRawFill = Queues.mpscArrayQueue(16);
fullArrayBlocking = new ArrayBlockingQueue<>(16);
fullLinkedBlocking = new LinkedBlockingQueue<>(16);
fullClq = new ConcurrentLinkedQueue<>();
fullClqCount = new AtomicInteger();
fullWork = WorkQueues.createMpscQueue(16);
for (int i = 0; i < 16; i++) {
Payload payload = new Payload(ELEMENT, i);
fullRaw.offer(payload);
fullRawFill.offer(payload);
fullArrayBlocking.offer(payload);
fullLinkedBlocking.offer(payload);
fullClq.offer(payload);
fullClqCount.incrementAndGet();
fullWork.tryPut(payload);
}
}

// ---------------------------------------------------------------------------------------------
// Steady: admit one, drain one, at a single thread. Every alternative at its best.
// ---------------------------------------------------------------------------------------------

@Benchmark
@Threads(1)
public void steadyRawMpsc(Blackhole bh) {
bh.consume(steadyRaw.offer(new Payload(ELEMENT, System.nanoTime())));
bh.consume(steadyRaw.poll());
}

/**
* The ring claiming before it builds, through the same factory {@link WorkQueue} uses. Against
* {@code steadyWorkQueue}, this is what the permit counter and the API cost on top of it.
*/
@Benchmark
@Threads(1)
public void steadyRawMpscFill(Blackhole bh) {
bh.consume(steadyRawFill.fill(RAW_BUILDER, 1));
bh.consume(steadyRawFill.poll());
}

/** {@code WafMetricCollector}: build, then offer, then find out. */
@Benchmark
@Threads(1)
public void steadyArrayBlocking(Blackhole bh) {
bh.consume(steadyArrayBlocking.offer(new Payload(ELEMENT, System.nanoTime())));
bh.consume(steadyArrayBlocking.poll());
}

/** {@code RumInjectorMetrics}: the same, over a linked queue. */
@Benchmark
@Threads(1)
public void steadyLinkedBlocking(Blackhole bh) {
bh.consume(steadyLinkedBlocking.offer(new Payload(ELEMENT, System.nanoTime())));
bh.consume(steadyLinkedBlocking.poll());
}

/** The hand-rolled bound: a read, then an increment, with a window between them. */
@Benchmark
@Threads(1)
public void steadyClqWithCounter(Blackhole bh) {
if (steadyClqCount.get() < CAPACITY) {
steadyClqCount.incrementAndGet();
bh.consume(steadyClq.offer(new Payload(ELEMENT, System.nanoTime())));
}
if (steadyClq.poll() != null) {
steadyClqCount.decrementAndGet();
}
}

@Benchmark
@Threads(1)
public void steadyWorkQueue(Blackhole bh) {
bh.consume(steadyWork.tryPut(BUILDER));
steadyWork.process(bh::consume);
}

// ---------------------------------------------------------------------------------------------
// Refused: a full queue, four threads. The boundary, where a bounded queue lives under load.
// ---------------------------------------------------------------------------------------------

@Benchmark
@Threads(4)
public void refusedRawMpsc(Blackhole bh) {
Payload payload = new Payload(ELEMENT, System.nanoTime());
bh.consume(fullRaw.offer(payload));
bh.consume(payload);
}

@Benchmark
@Threads(4)
public void refusedArrayBlocking(Blackhole bh) {
Payload payload = new Payload(ELEMENT, System.nanoTime());
bh.consume(fullArrayBlocking.offer(payload));
bh.consume(payload);
}

@Benchmark
@Threads(4)
public void refusedLinkedBlocking(Blackhole bh) {
Payload payload = new Payload(ELEMENT, System.nanoTime());
bh.consume(fullLinkedBlocking.offer(payload));
bh.consume(payload);
}

/**
* The one arm where the hand-rolled guard is doing what it was written for: refusing without
* touching the queue. It still builds the element first, because the caller had no way to know.
*/
@Benchmark
@Threads(4)
public void refusedClqWithCounter(Blackhole bh) {
Payload payload = new Payload(ELEMENT, System.nanoTime());
if (fullClqCount.get() < 16) {
fullClqCount.incrementAndGet();
bh.consume(fullClq.offer(payload));
}
bh.consume(payload);
}

/** The ring's own claim-before-build: a full ring returns 0 without calling the supplier. */
@Benchmark
@Threads(4)
public void refusedRawMpscFill(Blackhole bh) {
bh.consume(fullRawFill.fill(RAW_BUILDER, 1));
}

/** Never asked to build, because the place is claimed first and there was none. */
@Benchmark
@Threads(4)
public void refusedWorkQueue(Blackhole bh) {
bh.consume(fullWork.tryPut(BUILDER));
}
}
Loading
Loading