Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 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
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,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.
*
* <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 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.
*
* <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 -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.
*
* <pre>
* 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
* </pre>
*
* <p><b>What the two halves say.</b> 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.
*
* <p>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.
*
* <p><b>The honest caveat.</b> {@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<Payload> BUILDER = () -> new Payload(ELEMENT, System.nanoTime());

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

private MpscArrayQueue<Payload> fullRaw;
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);
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));
}
}
Loading