From 6fdbe965ef9caab2569f9d28108eb329f93bd07c Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 20:37:46 -0400 Subject: [PATCH 01/48] Add Queue admission and consumption API API surface only, no backing implementation yet. Co-Authored-By: Claude Opus 5 (1M context) --- .../datadog/common/queue/BatchProducer.java | 14 +++ .../common/queue/ContextualProducer.java | 12 +++ .../java/datadog/common/queue/MaxRetries.java | 16 +++ .../java/datadog/common/queue/Producer.java | 14 +++ .../main/java/datadog/common/queue/Queue.java | 101 ++++++++++++++++++ .../java/datadog/common/queue/RetryQueue.java | 22 ++++ .../datadog/common/queue/RetryStrategy.java | 17 +++ 7 files changed, 196 insertions(+) create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/BatchProducer.java create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/ContextualProducer.java create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/MaxRetries.java create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/Producer.java create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/Queue.java create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/RetryStrategy.java diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/BatchProducer.java b/utils/queue-utils/src/main/java/datadog/common/queue/BatchProducer.java new file mode 100644 index 00000000000..bb4b4d353e2 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BatchProducer.java @@ -0,0 +1,14 @@ +package datadog.common.queue; + +/** + * Supplies a sequence of elements that a {@link Queue} pulls incrementally as capacity allows. + * + *

Used by {@link Queue#put(BatchProducer)} for lossless admission: the queue drives the + * iteration, so elements are constructed only as slots become available rather than materialised up + * front. + */ +public interface BatchProducer { + boolean hasNext(); + + T next(); +} 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..12c9383303f --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/ContextualProducer.java @@ -0,0 +1,12 @@ +package datadog.common.queue; + +/** + * 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. + */ +@FunctionalInterface +public interface ContextualProducer { + T produce(C context); +} 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..d0bbea8aac1 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MaxRetries.java @@ -0,0 +1,16 @@ +package datadog.common.queue; + +/** A {@link RetryStrategy} that resubmits an item until a fixed attempt count is reached. */ +public final class MaxRetries implements RetryStrategy { + private final int maxRetries; + + public MaxRetries(int maxRetries) { + this.maxRetries = maxRetries; + } + + @Override + @SuppressWarnings("unchecked") + 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/Producer.java b/utils/queue-utils/src/main/java/datadog/common/queue/Producer.java new file mode 100644 index 00000000000..458793233ca --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/Producer.java @@ -0,0 +1,14 @@ +package datadog.common.queue; + +/** + * Produces an element for admission into a {@link Queue}. + * + *

A producer is only invoked once a slot has been reserved, so it is never called for an element + * that will be rejected. Implementations are expected to be non-capturing {@code static final} + * singletons; a capturing lambda allocates per call and defeats the purpose of deferring + * construction. + */ +@FunctionalInterface +public interface Producer { + T produce(); +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/Queue.java b/utils/queue-utils/src/main/java/datadog/common/queue/Queue.java new file mode 100644 index 00000000000..bcf74ce6d36 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/Queue.java @@ -0,0 +1,101 @@ +package datadog.common.queue; + +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. + * + *

Capacity is fixed by construction. A queue never grows in response to fullness: full means + * drop and count. Admission reserves a slot 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. + * + *

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. + */ +public interface Queue { + + /** + * @return whether the element was admitted + */ + boolean tryPut(T element); + + /** + * Admits an element, constructing it only once a slot is reserved. + * + * @return whether the element was admitted + */ + boolean tryPut(Producer producer); + + /** + * Admits an element derived from {@code context}, constructing it only once a slot is reserved. + * + * @return whether the element was admitted + */ + boolean tryPut(C context, ContextualProducer 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 tryPut(Collection elements); + + /** Admits every element the producer yields, pulling them as capacity allows. */ + void put(BatchProducer batchProducer); + + /** + * @return whether there was an item to consume + */ + boolean process(Consumer consumer); + + /** + * @return whether there was an item to consume + */ + boolean process(Consumer consumer, RetryStrategy retryStrategy); + + /** + * @return whether there was an item to consume + */ + boolean process(C context, BiConsumer consumer); + + /** + * @return whether there was an item to consume + */ + boolean process( + C context, BiConsumer consumer, RetryStrategy retryStrategy); + + int size(); + + /** + * @return how many elements have been rejected over this queue's lifetime + */ + long dropped(); + + /** + * Stops future admission, leaving current contents alone so a consumer can finish its backlog. + * + *

Rejection after closing is distinguishable from an ordinary full-capacity rejection, so a + * caller can tell "transiently full, worth retrying" from "permanently done". + */ + void close(); + + /** Discards current contents without affecting admission. */ + void clear(); + + /** + * Atomically {@link #close() closes} and {@link #clear() clears}. + * + *

Sequencing the two separately leaves a window — a producer already past the closed check, an + * in-flight retry lease — through which work can land in a queue nothing will drain again. + */ + void shutdown(); +} 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..7ba1fa5c503 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java @@ -0,0 +1,22 @@ +package datadog.common.queue; + +/** + * 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 one or more items in place of the failed item. + * + *

Resubmitting a single item reuses the lease the failed item already holds and so cannot fail + * on capacity. Resubmitting several — partitioning failed work into smaller pieces — needs the + * additional slots, and is a no-op returning {@code false} if they cannot be reserved; the + * original item stays leased and is retried later. + * + * @return whether the items were resubmitted + */ + @SuppressWarnings("unchecked") + boolean retry(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..0ef19ec8162 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/RetryStrategy.java @@ -0,0 +1,17 @@ +package datadog.common.queue; + +/** + * 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. + */ +@FunctionalInterface +public interface RetryStrategy { + /** + * @param attempt how many times this item has already been consumed unsuccessfully + * @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); +} From 76eeeb1cd5eb38bef0e75215cd43578248e0f7db Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 20:56:24 -0400 Subject: [PATCH 02/48] Add MPSC and linked-queue backings behind Queue Two implementations, both package-private and reachable only through Queues factories: - MpscBoundedQueue wraps a JCTools MPSC array queue. Reserve-first admission is the backing queue's own fill(Supplier, 1), which CAS-claims the slot before calling the supplier and returns zero without calling it at all when full. - LinkedQueue wraps a ConcurrentLinkedQueue for multi-consumer call sites, optionally bounded. A size counter makes the bound enforceable and size() constant-time. Transitional: it keeps the per-element node. Shared admission, lifecycle and retry logic lives in BaseQueue. RetryStrategy is invariant in the process() signatures: the ticket's RetryStrategy cannot typecheck, since a strategy over a supertype would need a RetryQueue the queue cannot satisfy. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/datadog/common/queue/BaseQueue.java | 236 +++++++++++++++++ .../datadog/common/queue/LinkedQueue.java | 92 +++++++ .../common/queue/MpscBoundedQueue.java | 71 +++++ .../main/java/datadog/common/queue/Queue.java | 14 +- .../java/datadog/common/queue/Queues.java | 40 +++ .../datadog/common/queue/RetryStrategy.java | 3 +- .../common/queue/MpscQueueStressTest.java | 167 ++++++++++++ .../common/queue/QueueContractTest.java | 242 ++++++++++++++++++ 8 files changed, 860 insertions(+), 5 deletions(-) create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/BaseQueue.java create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/LinkedQueue.java create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/MpscBoundedQueue.java create mode 100644 utils/queue-utils/src/test/java/datadog/common/queue/MpscQueueStressTest.java create mode 100644 utils/queue-utils/src/test/java/datadog/common/queue/QueueContractTest.java diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/BaseQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/BaseQueue.java new file mode 100644 index 00000000000..19f6bfbb05e --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseQueue.java @@ -0,0 +1,236 @@ +package datadog.common.queue; + +import static java.util.Collections.emptyList; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.atomic.LongAdder; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * Everything a {@link Queue} does that does not depend on how elements are stored: admission + * bookkeeping, the closed flag, drop counting, and the consume-and-maybe-retry cycle. + * + *

Subclasses supply four storage primitives. {@link #admit(Object)} and {@link #admit(Object, + * ContextualProducer)} must both claim a slot before storing anything, and the producing form must + * not invoke the producer unless the claim succeeded — that is the contract this whole API exists + * to provide. + */ +abstract class BaseQueue implements Queue { + + /** + * 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 Retried { + final T item; + final int attempt; + + Retried(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; + + private static final ContextualProducer, Object> NEXT = BatchProducer::next; + + private final LongAdder dropped = new LongAdder(); + private volatile boolean closed; + + /** + * Stores an already-built element, claiming a slot first. + * + * @return whether a slot was claimed and the element stored + */ + abstract boolean admit(Object element); + + /** + * Claims a slot and only then invokes the producer to build the element. + * + * @return whether a slot was claimed and the element stored + */ + abstract boolean admit(C context, ContextualProducer producer); + + /** + * @return the next stored object, or {@code null} if there was none + */ + abstract Object take(); + + abstract void discardAll(); + + @Override + public boolean tryPut(T element) { + return record(!closed && admit(element)); + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public boolean tryPut(Producer producer) { + return record(!closed && admit(producer, (ContextualProducer) PRODUCE)); + } + + @Override + public boolean tryPut(C context, ContextualProducer producer) { + return record(!closed && admit(context, producer)); + } + + @Override + @SafeVarargs + public final Collection tryPutBatch(T... elements) { + List rejected = null; + for (T element : elements) { + if (!tryPut(element)) { + if (rejected == null) { + rejected = new ArrayList<>(); + } + rejected.add(element); + } + } + return rejected == null ? emptyList() : rejected; + } + + @Override + public Collection tryPut(Collection elements) { + List rejected = null; + for (T element : elements) { + if (!tryPut(element)) { + if (rejected == null) { + rejected = new ArrayList<>(); + } + rejected.add(element); + } + } + return rejected == null ? emptyList() : rejected; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public void put(BatchProducer batchProducer) { + // Nothing is lost by stopping early: an element is pulled only once a slot is claimed, so + // whatever we did not take is still held by the producer. + while (!closed && batchProducer.hasNext() && admit(batchProducer, (ContextualProducer) NEXT)) { + // keep pulling + } + } + + @Override + public boolean process(Consumer consumer) { + return process(consumer, (RetryStrategy) null); + } + + @Override + public boolean process(Consumer consumer, RetryStrategy retryStrategy) { + Object raw = take(); + if (raw == null) { + return false; + } + consume(raw, consumer, null, null, retryStrategy); + return true; + } + + @Override + public boolean process(C context, BiConsumer consumer) { + return process(context, consumer, (RetryStrategy) null); + } + + @Override + public boolean process( + C context, BiConsumer consumer, RetryStrategy retryStrategy) { + Object raw = take(); + if (raw == null) { + return false; + } + consume(raw, null, context, consumer, retryStrategy); + return true; + } + + @SuppressWarnings("unchecked") + private void consume( + Object raw, + Consumer consumer, + C context, + BiConsumer biConsumer, + RetryStrategy retryStrategy) { + T item; + int attempt; + if (raw instanceof Retried) { + Retried retried = (Retried) raw; + item = retried.item; + attempt = retried.attempt; + } else { + item = (T) raw; + attempt = 0; + } + try { + if (consumer != null) { + consumer.accept(item); + } else { + biConsumer.accept(context, item); + } + } catch (Throwable failure) { + onFailure(item, attempt + 1, failure, retryStrategy); + } + } + + private void onFailure(T item, int attempt, Throwable failure, RetryStrategy retryStrategy) { + if (retryStrategy == null || !retryStrategy.onFailure(item, attempt, failure, lease(attempt))) { + dropped.increment(); + } + } + + /** Allocated only once a consumer has thrown, and never escapes {@link #onFailure}. */ + private RetryQueue lease(int attempt) { + return new RetryQueue() { + @Override + @SuppressWarnings("unchecked") + public boolean retry(T... items) { + boolean all = items.length > 0; + for (T item : items) { + if (closed || !admit(new Retried<>(item, attempt))) { + dropped.increment(); + all = false; + } + } + return all; + } + }; + } + + private boolean record(boolean admitted) { + if (!admitted) { + dropped.increment(); + } + return admitted; + } + + @Override + public long dropped() { + return dropped.sum(); + } + + @Override + public void close() { + closed = true; + } + + @Override + public boolean isClosed() { + return closed; + } + + @Override + public void clear() { + discardAll(); + } + + @Override + public void shutdown() { + closed = true; + discardAll(); + } +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedQueue.java new file mode 100644 index 00000000000..739f1d487b7 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedQueue.java @@ -0,0 +1,92 @@ +package datadog.common.queue; + +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * A {@link Queue} over a {@link ConcurrentLinkedQueue}: multi-producer, multi-consumer, optionally + * bounded. + * + *

This backing exists to give call sites that cannot yet take an MPSC ring — because they have + * several consumers, or no defensible capacity — the admission and lifecycle contract anyway, so + * they can be migrated behind {@link Queue} first and re-backed later. It keeps the linked queue's + * per-element node, so it does not deliver the allocation win; prefer {@link MpscBoundedQueue}. + * + *

The size counter is not merely bookkeeping. It is what makes the bound enforceable and {@link + * #size()} constant-time, replacing the hand-rolled cap plus O(n) {@code ConcurrentLinkedQueue + * .size()} walk that call sites otherwise pay on every admission. + */ +final class LinkedQueue extends BaseQueue { + + private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); + private final AtomicInteger size = new AtomicInteger(); + private final int capacity; + + /** + * @param capacity the bound, or {@link Integer#MAX_VALUE} to leave the queue unbounded + */ + LinkedQueue(int capacity) { + this.capacity = capacity; + } + + @Override + boolean admit(Object element) { + if (!reserve()) { + return false; + } + queue.offer(element); + return true; + } + + @Override + boolean admit(C context, ContextualProducer producer) { + if (!reserve()) { + return false; + } + T element; + try { + element = producer.produce(context); + } catch (Throwable t) { + size.decrementAndGet(); + throw t; + } + queue.offer(element); + return true; + } + + private boolean reserve() { + if (capacity == Integer.MAX_VALUE) { + size.incrementAndGet(); + return true; + } + int current; + do { + current = size.get(); + if (current >= capacity) { + return false; + } + } while (!size.compareAndSet(current, current + 1)); + return true; + } + + @Override + Object take() { + Object element = queue.poll(); + if (element != null) { + size.decrementAndGet(); + } + return element; + } + + @Override + void discardAll() { + while (take() != null) { + // drain + } + } + + @Override + public int size() { + return size.get(); + } +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/MpscBoundedQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/MpscBoundedQueue.java new file mode 100644 index 00000000000..b5cbcd37a1b --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MpscBoundedQueue.java @@ -0,0 +1,71 @@ +package datadog.common.queue; + +import org.jctools.queues.MessagePassingQueue; + +/** + * A {@link Queue} over a JCTools MPSC array queue: many producers, one consumer, bounded by + * construction with no per-element node. + * + *

Reserve-before-construct is the backing queue's own {@code fill(Supplier, 1)}, which + * CAS-claims the slot and only then calls the supplier, returning zero without ever calling it when + * there is no room. That makes admission exact rather than best-effort: a rejected element is not + * merely discarded cheaply, it is never built. + */ +final class MpscBoundedQueue extends BaseQueue { + + /** + * Handed to {@code fill} so the producer runs inside the claimed slot. One small short-lived + * object per producing admission, which never escapes the {@code fill} call and so is a candidate + * for scalar replacement; the payload it defers building is the allocation that matters. + */ + private static final class ProducingSupplier + implements MessagePassingQueue.Supplier { + private final C context; + private final ContextualProducer producer; + + ProducingSupplier(C context, ContextualProducer producer) { + this.context = context; + this.producer = producer; + } + + @Override + public Object get() { + return producer.produce(context); + } + } + + private final MessagePassingQueue queue; + + MpscBoundedQueue(int requestedCapacity) { + this.queue = Queues.mpscArrayQueue(requestedCapacity); + } + + @Override + boolean admit(Object element) { + return queue.offer(element); + } + + @Override + boolean admit(C context, ContextualProducer producer) { + return queue.fill(new ProducingSupplier<>(context, producer), 1) == 1; + } + + @Override + Object take() { + return queue.poll(); + } + + @Override + void discardAll() { + queue.clear(); + } + + @Override + public int size() { + return queue.size(); + } + + int capacity() { + return queue.capacity(); + } +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/Queue.java b/utils/queue-utils/src/main/java/datadog/common/queue/Queue.java index bcf74ce6d36..a95e06b725e 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/Queue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/Queue.java @@ -13,6 +13,10 @@ * 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. * + *

Because the slot is claimed first, a producer runs while holding capacity a consumer may be + * waiting on. Producers should build their element and nothing else: work that blocks, or that + * takes appreciably longer than an allocation, stalls the consumer rather than merely the producer. + * *

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. @@ -60,7 +64,7 @@ public interface Queue { /** * @return whether there was an item to consume */ - boolean process(Consumer consumer, RetryStrategy retryStrategy); + boolean process(Consumer consumer, RetryStrategy retryStrategy); /** * @return whether there was an item to consume @@ -71,7 +75,7 @@ public interface Queue { * @return whether there was an item to consume */ boolean process( - C context, BiConsumer consumer, RetryStrategy retryStrategy); + C context, BiConsumer consumer, RetryStrategy retryStrategy); int size(); @@ -83,11 +87,13 @@ boolean process( /** * Stops future admission, leaving current contents alone so a consumer can finish its backlog. * - *

Rejection after closing is distinguishable from an ordinary full-capacity rejection, so a - * caller can tell "transiently full, worth retrying" from "permanently done". + *

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(); 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..4ceb7cb67c4 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 @@ -89,4 +89,44 @@ public static MessagePassingQueue spscArrayQueue(int requestedCapacity) { } return new SpscArrayQueue<>(requestedCapacity); } + + /** + * Creates a bounded Multiple Producer, Single Consumer {@link Queue} backed by an MPSC array + * queue. + * + *

The preferred backing: no per-element node, constant-time {@link Queue#size()}, and + * admission that claims a slot before invoking a producer, so an element that will not fit is + * never built. + * + * @param requestedCapacity the bound. Will be rounded to the next power of two. + */ + public static Queue mpscQueue(int requestedCapacity) { + return new MpscBoundedQueue<>(requestedCapacity); + } + + /** + * Creates a bounded Multiple Producer, Multiple Consumer {@link Queue} backed by a {@link + * java.util.concurrent.ConcurrentLinkedQueue}. + * + *

For call sites that need several consumers. It keeps the linked queue's per-element node, so + * it buys the admission and lifecycle contract but not the allocation win — prefer {@link + * #mpscQueue} where a single consumer is possible. + * + * @param capacity the bound + */ + public static Queue mpmcQueue(int capacity) { + return new LinkedQueue<>(capacity); + } + + /** + * Creates an unbounded Multiple Producer, Multiple Consumer {@link Queue} backed by a {@link + * java.util.concurrent.ConcurrentLinkedQueue}. + * + *

Unbounded means admission never rejects and {@link Queue#dropped()} only ever counts items + * abandoned by a retry strategy. Intended as a migration step for call sites that are unbounded + * today: adopt the interface here, then pick a bound and move to {@link #mpscQueue}. + */ + public static Queue unboundedMpmcQueue() { + return new LinkedQueue<>(Integer.MAX_VALUE); + } } 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 index 0ef19ec8162..f4c478865c7 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/RetryStrategy.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/RetryStrategy.java @@ -10,7 +10,8 @@ @FunctionalInterface public interface RetryStrategy { /** - * @param attempt how many times this item has already been consumed unsuccessfully + * @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/test/java/datadog/common/queue/MpscQueueStressTest.java b/utils/queue-utils/src/test/java/datadog/common/queue/MpscQueueStressTest.java new file mode 100644 index 00000000000..d422c8d1161 --- /dev/null +++ b/utils/queue-utils/src/test/java/datadog/common/queue/MpscQueueStressTest.java @@ -0,0 +1,167 @@ +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 element it was told was rejected must be counted as + * dropped — so admitted plus dropped accounts for everything offered, with nothing lost, duplicated + * or invented in between. + */ +class MpscQueueStressTest { + + 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 { + Queue queue = Queues.mpscQueue(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; + if (queue.tryPut(value)) { + admitted.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(), queue.dropped(), "every rejection is counted"); + 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 { + Queue queue = Queues.mpscQueue(CAPACITY); + while (queue.tryPut(0)) { + // fill it, and leave it full — nothing consumes + } + // the loop above ends on a rejection, which is itself a drop + long droppedWhileFilling = queue.dropped(); + + AtomicInteger produced = 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(); + } + } + } 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( + droppedWhileFilling + (long) PRODUCERS * PER_PRODUCER, + queue.dropped(), + "every rejected admission is counted"); + } + + 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/QueueContractTest.java b/utils/queue-utils/src/test/java/datadog/common/queue/QueueContractTest.java new file mode 100644 index 00000000000..7fd2b749e31 --- /dev/null +++ b/utils/queue-utils/src/test/java/datadog/common/queue/QueueContractTest.java @@ -0,0 +1,242 @@ +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.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +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 QueueContractTest { + + private static final int CAPACITY = 4; + + static Stream boundedQueues() { + return Stream.of( + Arguments.of("mpsc", (IntFunction>) Queues::mpscQueue), + Arguments.of("mpmc", (IntFunction>) Queues::mpmcQueue)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void admitsUpToCapacityThenDrops(String name, IntFunction> factory) { + Queue 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()); + assertEquals(1, queue.dropped()); + } + + /** The point of the whole API: a rejected element is never built. */ + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void doesNotInvokeProducerWhenFull(String name, IntFunction> factory) { + Queue 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) { + Queue queue = factory.apply(CAPACITY); + assertTrue(queue.tryPut("ctx", context -> context + "-built")); + List consumed = drain(queue); + assertEquals(Arrays.asList("ctx-built"), consumed); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void batchAdmissionReportsRejectedElements(String name, IntFunction> factory) { + Queue queue = factory.apply(CAPACITY); + Collection rejected = queue.tryPutBatch("a", "b", "c", "d", "e", "f"); + assertEquals(Arrays.asList("e", "f"), new ArrayList<>(rejected)); + assertEquals(2, queue.dropped()); + } + + /** A batch producer keeps what will not fit, so stopping early loses nothing. */ + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void batchProducerRetainsUnpulledElements(String name, IntFunction> factory) { + Queue queue = factory.apply(CAPACITY); + Iterator source = Arrays.asList("a", "b", "c", "d", "e", "f").iterator(); + BatchProducer producer = + new BatchProducer() { + @Override + public boolean hasNext() { + return source.hasNext(); + } + + @Override + public String next() { + return source.next(); + } + }; + + queue.put(producer); + + assertEquals(CAPACITY, queue.size()); + assertEquals(0, queue.dropped(), "stopping at capacity is not a drop"); + assertTrue(producer.hasNext(), "unpulled elements stay with the producer"); + assertEquals("e", producer.next()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void processReportsWhetherThereWasWork(String name, IntFunction> factory) { + Queue 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 processReportsWorkEvenWhenConsumerThrows(String name, IntFunction> factory) { + Queue queue = factory.apply(CAPACITY); + queue.tryPut("a"); + assertTrue( + queue.process( + item -> { + throw new IllegalStateException("boom"); + }), + "the return value reports work found, not consumer success"); + assertEquals(1, queue.dropped(), "an unretried failure loses the item"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void retriesUntilTheStrategyGivesUp(String name, IntFunction> factory) { + Queue 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.process( + 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"); + assertEquals(1, queue.dropped(), "giving up loses the item"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void maxRetriesBoundsResubmission(String name, IntFunction> factory) { + Queue queue = factory.apply(CAPACITY); + queue.tryPut("a"); + AtomicInteger attempts = new AtomicInteger(); + RetryStrategy strategy = new MaxRetries<>(3); + + while (queue.process( + item -> { + attempts.incrementAndGet(); + throw new IllegalStateException("boom"); + }, + strategy)) { + // drain + } + + assertEquals(3, attempts.get()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void closeStopsAdmissionButKeepsBacklog(String name, IntFunction> factory) { + Queue 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"), drain(queue)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void clearDiscardsContentsButLeavesAdmissionOpen( + String name, IntFunction> factory) { + Queue 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) { + Queue 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 unboundedQueueNeverRejects() { + Queue queue = Queues.unboundedMpmcQueue(); + for (int i = 0; i < 1000; i++) { + assertTrue(queue.tryPut("e" + i)); + } + assertEquals(1000, queue.size()); + assertEquals(0, queue.dropped()); + } + + @org.junit.jupiter.api.Test + void unboundedQueueStillCloses() { + Queue queue = Queues.unboundedMpmcQueue(); + queue.close(); + assertFalse(queue.tryPut("a")); + } + + private static List drain(Queue queue) { + List consumed = new ArrayList<>(); + while (queue.process(consumed::add)) { + // drain + } + return consumed; + } +} From cbb32c324b0fe4ecfd972251dfaaef2f6792440d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 20:59:27 -0400 Subject: [PATCH 03/48] Prefix Queue factory methods with create Co-Authored-By: Claude Opus 5 (1M context) --- .../src/main/java/datadog/common/queue/Queues.java | 10 +++++----- .../java/datadog/common/queue/MpscQueueStressTest.java | 4 ++-- .../java/datadog/common/queue/QueueContractTest.java | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) 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 4ceb7cb67c4..bcaf41c7309 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 @@ -100,7 +100,7 @@ public static MessagePassingQueue spscArrayQueue(int requestedCapacity) { * * @param requestedCapacity the bound. Will be rounded to the next power of two. */ - public static Queue mpscQueue(int requestedCapacity) { + public static Queue createMpscQueue(int requestedCapacity) { return new MpscBoundedQueue<>(requestedCapacity); } @@ -110,11 +110,11 @@ public static Queue mpscQueue(int requestedCapacity) { * *

For call sites that need several consumers. It keeps the linked queue's per-element node, so * it buys the admission and lifecycle contract but not the allocation win — prefer {@link - * #mpscQueue} where a single consumer is possible. + * #createMpscQueue} where a single consumer is possible. * * @param capacity the bound */ - public static Queue mpmcQueue(int capacity) { + public static Queue createMpmcQueue(int capacity) { return new LinkedQueue<>(capacity); } @@ -124,9 +124,9 @@ public static Queue mpmcQueue(int capacity) { * *

Unbounded means admission never rejects and {@link Queue#dropped()} only ever counts items * abandoned by a retry strategy. Intended as a migration step for call sites that are unbounded - * today: adopt the interface here, then pick a bound and move to {@link #mpscQueue}. + * today: adopt the interface here, then pick a bound and move to {@link #createMpscQueue}. */ - public static Queue unboundedMpmcQueue() { + public static Queue createUnboundedMpmcQueue() { return new LinkedQueue<>(Integer.MAX_VALUE); } } diff --git a/utils/queue-utils/src/test/java/datadog/common/queue/MpscQueueStressTest.java b/utils/queue-utils/src/test/java/datadog/common/queue/MpscQueueStressTest.java index d422c8d1161..0dbe38075b5 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/MpscQueueStressTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/MpscQueueStressTest.java @@ -30,7 +30,7 @@ class MpscQueueStressTest { @Test void conservesEveryElementUnderContention() throws Exception { - Queue queue = Queues.mpscQueue(CAPACITY); + Queue queue = Queues.createMpscQueue(CAPACITY); AtomicIntegerArray timesSeen = new AtomicIntegerArray(TOTAL); AtomicInteger admitted = new AtomicInteger(); AtomicInteger consumed = new AtomicInteger(); @@ -106,7 +106,7 @@ void conservesEveryElementUnderContention() throws Exception { */ @Test void neverInvokesProducerWhileFull() throws Exception { - Queue queue = Queues.mpscQueue(CAPACITY); + Queue queue = Queues.createMpscQueue(CAPACITY); while (queue.tryPut(0)) { // fill it, and leave it full — nothing consumes } diff --git a/utils/queue-utils/src/test/java/datadog/common/queue/QueueContractTest.java b/utils/queue-utils/src/test/java/datadog/common/queue/QueueContractTest.java index 7fd2b749e31..0be2e605474 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/QueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/QueueContractTest.java @@ -24,8 +24,8 @@ class QueueContractTest { static Stream boundedQueues() { return Stream.of( - Arguments.of("mpsc", (IntFunction>) Queues::mpscQueue), - Arguments.of("mpmc", (IntFunction>) Queues::mpmcQueue)); + Arguments.of("mpsc", (IntFunction>) Queues::createMpscQueue), + Arguments.of("mpmc", (IntFunction>) Queues::createMpmcQueue)); } @ParameterizedTest(name = "{0}") @@ -217,7 +217,7 @@ void shutdownClosesAndDiscards(String name, IntFunction> factory) @org.junit.jupiter.api.Test void unboundedQueueNeverRejects() { - Queue queue = Queues.unboundedMpmcQueue(); + Queue queue = Queues.createUnboundedMpmcQueue(); for (int i = 0; i < 1000; i++) { assertTrue(queue.tryPut("e" + i)); } @@ -227,7 +227,7 @@ void unboundedQueueNeverRejects() { @org.junit.jupiter.api.Test void unboundedQueueStillCloses() { - Queue queue = Queues.unboundedMpmcQueue(); + Queue queue = Queues.createUnboundedMpmcQueue(); queue.close(); assertFalse(queue.tryPut("a")); } From c1ce00af7454bcefe0087a4868b8742acfe8c593 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 21:06:29 -0400 Subject: [PATCH 04/48] Rename Queue to WorkQueue and split its factories from Queues Sets the new API apart from the raw JCTools factory and removes the java.util.Queue collision, so no caller has to qualify an import. Queue -> WorkQueue (+ WorkQueues factory) BaseQueue -> BaseWorkQueue MpscBoundedQueue -> MpscWorkQueue LinkedQueue -> LinkedWorkQueue Queues keeps only the raw MessagePassingQueue factories and is otherwise untouched, so its existing callers are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- .../{BaseQueue.java => BaseWorkQueue.java} | 4 +- .../datadog/common/queue/BatchProducer.java | 4 +- ...{LinkedQueue.java => LinkedWorkQueue.java} | 13 ++-- ...scBoundedQueue.java => MpscWorkQueue.java} | 6 +- .../java/datadog/common/queue/Producer.java | 2 +- .../java/datadog/common/queue/Queues.java | 40 ------------ .../queue/{Queue.java => WorkQueue.java} | 2 +- .../java/datadog/common/queue/WorkQueues.java | 56 +++++++++++++++++ ...Test.java => MpscWorkQueueStressTest.java} | 6 +- ...ctTest.java => WorkQueueContractTest.java} | 61 ++++++++++--------- 10 files changed, 106 insertions(+), 88 deletions(-) rename utils/queue-utils/src/main/java/datadog/common/queue/{BaseQueue.java => BaseWorkQueue.java} (97%) rename utils/queue-utils/src/main/java/datadog/common/queue/{LinkedQueue.java => LinkedWorkQueue.java} (83%) rename utils/queue-utils/src/main/java/datadog/common/queue/{MpscBoundedQueue.java => MpscWorkQueue.java} (90%) rename utils/queue-utils/src/main/java/datadog/common/queue/{Queue.java => WorkQueue.java} (99%) create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java rename utils/queue-utils/src/test/java/datadog/common/queue/{MpscQueueStressTest.java => MpscWorkQueueStressTest.java} (97%) rename utils/queue-utils/src/test/java/datadog/common/queue/{QueueContractTest.java => WorkQueueContractTest.java} (77%) diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/BaseQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java similarity index 97% rename from utils/queue-utils/src/main/java/datadog/common/queue/BaseQueue.java rename to utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java index 19f6bfbb05e..719a37a0128 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -10,7 +10,7 @@ import java.util.function.Consumer; /** - * Everything a {@link Queue} does that does not depend on how elements are stored: admission + * Everything a {@link WorkQueue} does that does not depend on how elements are stored: admission * bookkeeping, the closed flag, drop counting, and the consume-and-maybe-retry cycle. * *

Subclasses supply four storage primitives. {@link #admit(Object)} and {@link #admit(Object, @@ -18,7 +18,7 @@ * not invoke the producer unless the claim succeeded — that is the contract this whole API exists * to provide. */ -abstract class BaseQueue implements Queue { +abstract class BaseWorkQueue implements WorkQueue { /** * Wraps an item that has already failed, carrying its attempt count back into the queue. Only diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/BatchProducer.java b/utils/queue-utils/src/main/java/datadog/common/queue/BatchProducer.java index bb4b4d353e2..d4a2df2ba8b 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BatchProducer.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BatchProducer.java @@ -1,9 +1,9 @@ package datadog.common.queue; /** - * Supplies a sequence of elements that a {@link Queue} pulls incrementally as capacity allows. + * Supplies a sequence of elements that a {@link WorkQueue} pulls incrementally as capacity allows. * - *

Used by {@link Queue#put(BatchProducer)} for lossless admission: the queue drives the + *

Used by {@link WorkQueue#put(BatchProducer)} for lossless admission: the queue drives the * iteration, so elements are constructed only as slots become available rather than materialised up * front. */ diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java similarity index 83% rename from utils/queue-utils/src/main/java/datadog/common/queue/LinkedQueue.java rename to utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java index 739f1d487b7..009b553b949 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java @@ -4,19 +4,20 @@ import java.util.concurrent.atomic.AtomicInteger; /** - * A {@link Queue} over a {@link ConcurrentLinkedQueue}: multi-producer, multi-consumer, optionally - * bounded. + * A {@link WorkQueue} over a {@link ConcurrentLinkedQueue}: multi-producer, multi-consumer, + * optionally bounded. * *

This backing exists to give call sites that cannot yet take an MPSC ring — because they have * several consumers, or no defensible capacity — the admission and lifecycle contract anyway, so - * they can be migrated behind {@link Queue} first and re-backed later. It keeps the linked queue's - * per-element node, so it does not deliver the allocation win; prefer {@link MpscBoundedQueue}. + * they can be migrated behind {@link WorkQueue} first and re-backed later. It keeps the linked + * queue's per-element node, so it does not deliver the allocation win; prefer {@link + * MpscWorkQueue}. * *

The size counter is not merely bookkeeping. It is what makes the bound enforceable and {@link * #size()} constant-time, replacing the hand-rolled cap plus O(n) {@code ConcurrentLinkedQueue * .size()} walk that call sites otherwise pay on every admission. */ -final class LinkedQueue extends BaseQueue { +final class LinkedWorkQueue extends BaseWorkQueue { private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); private final AtomicInteger size = new AtomicInteger(); @@ -25,7 +26,7 @@ final class LinkedQueue extends BaseQueue { /** * @param capacity the bound, or {@link Integer#MAX_VALUE} to leave the queue unbounded */ - LinkedQueue(int capacity) { + LinkedWorkQueue(int capacity) { this.capacity = capacity; } diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/MpscBoundedQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java similarity index 90% rename from utils/queue-utils/src/main/java/datadog/common/queue/MpscBoundedQueue.java rename to utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java index b5cbcd37a1b..0fbc85a6962 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/MpscBoundedQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java @@ -3,7 +3,7 @@ import org.jctools.queues.MessagePassingQueue; /** - * A {@link Queue} over a JCTools MPSC array queue: many producers, one consumer, bounded by + * A {@link WorkQueue} over a JCTools MPSC array queue: many producers, one consumer, bounded by * construction with no per-element node. * *

Reserve-before-construct is the backing queue's own {@code fill(Supplier, 1)}, which @@ -11,7 +11,7 @@ * there is no room. That makes admission exact rather than best-effort: a rejected element is not * merely discarded cheaply, it is never built. */ -final class MpscBoundedQueue extends BaseQueue { +final class MpscWorkQueue extends BaseWorkQueue { /** * Handed to {@code fill} so the producer runs inside the claimed slot. One small short-lived @@ -36,7 +36,7 @@ public Object get() { private final MessagePassingQueue queue; - MpscBoundedQueue(int requestedCapacity) { + MpscWorkQueue(int requestedCapacity) { this.queue = Queues.mpscArrayQueue(requestedCapacity); } 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 index 458793233ca..acb884355d2 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/Producer.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/Producer.java @@ -1,7 +1,7 @@ package datadog.common.queue; /** - * Produces an element for admission into a {@link Queue}. + * Produces an element for admission into a {@link WorkQueue}. * *

A producer is only invoked once a slot has been reserved, so it is never called for an element * that will be rejected. Implementations are expected to be non-capturing {@code static final} 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 bcaf41c7309..9c3de5fac8a 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 @@ -89,44 +89,4 @@ public static MessagePassingQueue spscArrayQueue(int requestedCapacity) { } return new SpscArrayQueue<>(requestedCapacity); } - - /** - * Creates a bounded Multiple Producer, Single Consumer {@link Queue} backed by an MPSC array - * queue. - * - *

The preferred backing: no per-element node, constant-time {@link Queue#size()}, and - * admission that claims a slot before invoking a producer, so an element that will not fit is - * never built. - * - * @param requestedCapacity the bound. Will be rounded to the next power of two. - */ - public static Queue createMpscQueue(int requestedCapacity) { - return new MpscBoundedQueue<>(requestedCapacity); - } - - /** - * Creates a bounded Multiple Producer, Multiple Consumer {@link Queue} backed by a {@link - * java.util.concurrent.ConcurrentLinkedQueue}. - * - *

For call sites that need several consumers. It keeps the linked queue's per-element node, so - * it buys the admission and lifecycle contract but not the allocation win — prefer {@link - * #createMpscQueue} where a single consumer is possible. - * - * @param capacity the bound - */ - public static Queue createMpmcQueue(int capacity) { - return new LinkedQueue<>(capacity); - } - - /** - * Creates an unbounded Multiple Producer, Multiple Consumer {@link Queue} backed by a {@link - * java.util.concurrent.ConcurrentLinkedQueue}. - * - *

Unbounded means admission never rejects and {@link Queue#dropped()} only ever counts items - * abandoned by a retry strategy. 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}. - */ - public static Queue createUnboundedMpmcQueue() { - return new LinkedQueue<>(Integer.MAX_VALUE); - } } diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/Queue.java b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java similarity index 99% rename from utils/queue-utils/src/main/java/datadog/common/queue/Queue.java rename to utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java index a95e06b725e..ea247ca74b8 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/Queue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -21,7 +21,7 @@ * {@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. */ -public interface Queue { +public interface WorkQueue { /** * @return whether the element was admitted 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..3381489e302 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java @@ -0,0 +1,56 @@ +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. + * + * @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 a {@link + * ConcurrentLinkedQueue}. + * + *

For call sites that need several consumers. It keeps the linked queue's per-element node, so + * it buys the admission and lifecycle contract but not the allocation win — prefer {@link + * #createMpscQueue} where a single consumer is possible. + * + * @param capacity the bound + */ + public static WorkQueue createMpmcQueue(int capacity) { + return new LinkedWorkQueue<>(capacity); + } + + /** + * Creates an unbounded Multiple Producer, Multiple Consumer buffer backed by a {@link + * ConcurrentLinkedQueue}. + * + *

Unbounded means admission never rejects and {@link WorkQueue#dropped()} only ever counts + * items abandoned by a retry strategy. 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}. + */ + public static WorkQueue createUnboundedMpmcQueue() { + return new LinkedWorkQueue<>(Integer.MAX_VALUE); + } +} diff --git a/utils/queue-utils/src/test/java/datadog/common/queue/MpscQueueStressTest.java b/utils/queue-utils/src/test/java/datadog/common/queue/MpscWorkQueueStressTest.java similarity index 97% rename from utils/queue-utils/src/test/java/datadog/common/queue/MpscQueueStressTest.java rename to utils/queue-utils/src/test/java/datadog/common/queue/MpscWorkQueueStressTest.java index 0dbe38075b5..5015353cd4a 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/MpscQueueStressTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/MpscWorkQueueStressTest.java @@ -20,7 +20,7 @@ * dropped — so admitted plus dropped accounts for everything offered, with nothing lost, duplicated * or invented in between. */ -class MpscQueueStressTest { +class MpscWorkQueueStressTest { private static final int PRODUCERS = 8; private static final int PER_PRODUCER = 20_000; @@ -30,7 +30,7 @@ class MpscQueueStressTest { @Test void conservesEveryElementUnderContention() throws Exception { - Queue queue = Queues.createMpscQueue(CAPACITY); + WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); AtomicIntegerArray timesSeen = new AtomicIntegerArray(TOTAL); AtomicInteger admitted = new AtomicInteger(); AtomicInteger consumed = new AtomicInteger(); @@ -106,7 +106,7 @@ void conservesEveryElementUnderContention() throws Exception { */ @Test void neverInvokesProducerWhileFull() throws Exception { - Queue queue = Queues.createMpscQueue(CAPACITY); + WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); while (queue.tryPut(0)) { // fill it, and leave it full — nothing consumes } diff --git a/utils/queue-utils/src/test/java/datadog/common/queue/QueueContractTest.java b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java similarity index 77% rename from utils/queue-utils/src/test/java/datadog/common/queue/QueueContractTest.java rename to utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java index 0be2e605474..a238108911c 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/QueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -18,20 +18,20 @@ import org.junit.jupiter.params.provider.MethodSource; /** The behaviour every backing must share, exercised against each of them. */ -class QueueContractTest { +class WorkQueueContractTest { private static final int CAPACITY = 4; static Stream boundedQueues() { return Stream.of( - Arguments.of("mpsc", (IntFunction>) Queues::createMpscQueue), - Arguments.of("mpmc", (IntFunction>) Queues::createMpmcQueue)); + Arguments.of("mpsc", (IntFunction>) WorkQueues::createMpscQueue), + Arguments.of("mpmc", (IntFunction>) WorkQueues::createMpmcQueue)); } @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void admitsUpToCapacityThenDrops(String name, IntFunction> factory) { - Queue queue = factory.apply(CAPACITY); + void admitsUpToCapacityThenDrops(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); for (int i = 0; i < CAPACITY; i++) { assertTrue(queue.tryPut("e" + i)); } @@ -44,8 +44,8 @@ void admitsUpToCapacityThenDrops(String name, IntFunction> factory /** The point of the whole API: a rejected element is never built. */ @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void doesNotInvokeProducerWhenFull(String name, IntFunction> factory) { - Queue queue = factory.apply(CAPACITY); + void doesNotInvokeProducerWhenFull(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); for (int i = 0; i < CAPACITY; i++) { assertTrue(queue.tryPut("e" + i)); } @@ -62,8 +62,8 @@ void doesNotInvokeProducerWhenFull(String name, IntFunction> facto @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void invokesProducerWhenThereIsRoom(String name, IntFunction> factory) { - Queue queue = factory.apply(CAPACITY); + void invokesProducerWhenThereIsRoom(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); assertTrue(queue.tryPut("ctx", context -> context + "-built")); List consumed = drain(queue); assertEquals(Arrays.asList("ctx-built"), consumed); @@ -71,8 +71,8 @@ void invokesProducerWhenThereIsRoom(String name, IntFunction> fact @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void batchAdmissionReportsRejectedElements(String name, IntFunction> factory) { - Queue queue = factory.apply(CAPACITY); + 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)); assertEquals(2, queue.dropped()); @@ -81,8 +81,8 @@ void batchAdmissionReportsRejectedElements(String name, IntFunction> factory) { - Queue queue = factory.apply(CAPACITY); + void batchProducerRetainsUnpulledElements(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); Iterator source = Arrays.asList("a", "b", "c", "d", "e", "f").iterator(); BatchProducer producer = new BatchProducer() { @@ -107,8 +107,8 @@ public String next() { @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void processReportsWhetherThereWasWork(String name, IntFunction> factory) { - Queue queue = factory.apply(CAPACITY); + 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 -> {})); @@ -117,8 +117,9 @@ void processReportsWhetherThereWasWork(String name, IntFunction> f @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void processReportsWorkEvenWhenConsumerThrows(String name, IntFunction> factory) { - Queue queue = factory.apply(CAPACITY); + void processReportsWorkEvenWhenConsumerThrows( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); queue.tryPut("a"); assertTrue( queue.process( @@ -131,8 +132,8 @@ void processReportsWorkEvenWhenConsumerThrows(String name, IntFunction> factory) { - Queue queue = factory.apply(CAPACITY); + void retriesUntilTheStrategyGivesUp(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); queue.tryPut("a"); AtomicInteger attempts = new AtomicInteger(); List reported = new ArrayList<>(); @@ -159,8 +160,8 @@ void retriesUntilTheStrategyGivesUp(String name, IntFunction> fact @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void maxRetriesBoundsResubmission(String name, IntFunction> factory) { - Queue queue = factory.apply(CAPACITY); + void maxRetriesBoundsResubmission(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); queue.tryPut("a"); AtomicInteger attempts = new AtomicInteger(); RetryStrategy strategy = new MaxRetries<>(3); @@ -179,8 +180,8 @@ void maxRetriesBoundsResubmission(String name, IntFunction> factor @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void closeStopsAdmissionButKeepsBacklog(String name, IntFunction> factory) { - Queue queue = factory.apply(CAPACITY); + void closeStopsAdmissionButKeepsBacklog(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); queue.tryPut("a"); queue.close(); @@ -193,8 +194,8 @@ void closeStopsAdmissionButKeepsBacklog(String name, IntFunction> @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") void clearDiscardsContentsButLeavesAdmissionOpen( - String name, IntFunction> factory) { - Queue queue = factory.apply(CAPACITY); + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); queue.tryPutBatch("a", "b"); queue.clear(); @@ -205,8 +206,8 @@ void clearDiscardsContentsButLeavesAdmissionOpen( @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void shutdownClosesAndDiscards(String name, IntFunction> factory) { - Queue queue = factory.apply(CAPACITY); + void shutdownClosesAndDiscards(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); queue.tryPutBatch("a", "b"); queue.shutdown(); @@ -217,7 +218,7 @@ void shutdownClosesAndDiscards(String name, IntFunction> factory) @org.junit.jupiter.api.Test void unboundedQueueNeverRejects() { - Queue queue = Queues.createUnboundedMpmcQueue(); + WorkQueue queue = WorkQueues.createUnboundedMpmcQueue(); for (int i = 0; i < 1000; i++) { assertTrue(queue.tryPut("e" + i)); } @@ -227,12 +228,12 @@ void unboundedQueueNeverRejects() { @org.junit.jupiter.api.Test void unboundedQueueStillCloses() { - Queue queue = Queues.createUnboundedMpmcQueue(); + WorkQueue queue = WorkQueues.createUnboundedMpmcQueue(); queue.close(); assertFalse(queue.tryPut("a")); } - private static List drain(Queue queue) { + private static List drain(WorkQueue queue) { List consumed = new ArrayList<>(); while (queue.process(consumed::add)) { // drain From 0f009cfed02c3bd56865294ca774f78ae4cf4e94 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 21:39:35 -0400 Subject: [PATCH 05/48] Drop BatchProducer and put() until SCA needs them No use case on APMLP-1642 admits more than one element per call, so the batch admission protocol had no caller. SCA's partition-on-failure is the real one, and it should arrive with SCA in a follow-on so its access pattern drives the shape rather than a guess. When it returns it should hand the filler a scoped admission-only capability, in the manner of RetryQueue, rather than the WorkQueue itself: the full interface would expose close/shutdown/clear/process to arbitrary caller code, and letting the filler own the loop reintroduces the build-then-drop this API exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) --- .../datadog/common/queue/BaseWorkQueue.java | 12 -------- .../datadog/common/queue/BatchProducer.java | 14 ---------- .../java/datadog/common/queue/WorkQueue.java | 3 -- .../common/queue/WorkQueueContractTest.java | 28 ------------------- 4 files changed, 57 deletions(-) delete mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/BatchProducer.java 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 index 719a37a0128..0ea93e7145d 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -37,8 +37,6 @@ private static final class Retried { /** Non-capturing adapters, so the producer forms share one admission path without allocating. */ private static final ContextualProducer, Object> PRODUCE = Producer::produce; - private static final ContextualProducer, Object> NEXT = BatchProducer::next; - private final LongAdder dropped = new LongAdder(); private volatile boolean closed; @@ -108,16 +106,6 @@ public Collection tryPut(Collection elements) { return rejected == null ? emptyList() : rejected; } - @Override - @SuppressWarnings({"unchecked", "rawtypes"}) - public void put(BatchProducer batchProducer) { - // Nothing is lost by stopping early: an element is pulled only once a slot is claimed, so - // whatever we did not take is still held by the producer. - while (!closed && batchProducer.hasNext() && admit(batchProducer, (ContextualProducer) NEXT)) { - // keep pulling - } - } - @Override public boolean process(Consumer consumer) { return process(consumer, (RetryStrategy) null); diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/BatchProducer.java b/utils/queue-utils/src/main/java/datadog/common/queue/BatchProducer.java deleted file mode 100644 index d4a2df2ba8b..00000000000 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BatchProducer.java +++ /dev/null @@ -1,14 +0,0 @@ -package datadog.common.queue; - -/** - * Supplies a sequence of elements that a {@link WorkQueue} pulls incrementally as capacity allows. - * - *

Used by {@link WorkQueue#put(BatchProducer)} for lossless admission: the queue drives the - * iteration, so elements are constructed only as slots become available rather than materialised up - * front. - */ -public interface BatchProducer { - boolean hasNext(); - - T next(); -} 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 index ea247ca74b8..d0c3658097b 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -53,9 +53,6 @@ public interface WorkQueue { */ Collection tryPut(Collection elements); - /** Admits every element the producer yields, pulling them as capacity allows. */ - void put(BatchProducer batchProducer); - /** * @return whether there was an item to consume */ 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 index a238108911c..3e712af32c3 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -7,7 +7,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; -import java.util.Iterator; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -78,33 +77,6 @@ void batchAdmissionReportsRejectedElements(String name, IntFunction> factory) { - WorkQueue queue = factory.apply(CAPACITY); - Iterator source = Arrays.asList("a", "b", "c", "d", "e", "f").iterator(); - BatchProducer producer = - new BatchProducer() { - @Override - public boolean hasNext() { - return source.hasNext(); - } - - @Override - public String next() { - return source.next(); - } - }; - - queue.put(producer); - - assertEquals(CAPACITY, queue.size()); - assertEquals(0, queue.dropped(), "stopping at capacity is not a drop"); - assertTrue(producer.hasNext(), "unpulled elements stay with the producer"); - assertEquals("e", producer.next()); - } - @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") void processReportsWhetherThereWasWork(String name, IntFunction> factory) { From edbf5a51530bae2a7cdc2571ed5530c5af9a3865 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 22:02:37 -0400 Subject: [PATCH 06/48] Add a single-element RetryQueue.retry overload The varargs form allocated an array for the common case of resubmitting the one item that just failed. The single-element overload is what an ordinary strategy binds to now; the varargs form delegates to it. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 14 ++++++++--- .../java/datadog/common/queue/MaxRetries.java | 1 - .../java/datadog/common/queue/RetryQueue.java | 20 +++++++++++---- .../common/queue/WorkQueueContractTest.java | 25 +++++++++++++++++++ 4 files changed, 50 insertions(+), 10 deletions(-) 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 index 0ea93e7145d..56e8367f6b1 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -174,15 +174,21 @@ private void onFailure(T item, int attempt, Throwable failure, RetryStrategy /** Allocated only once a consumer has thrown, and never escapes {@link #onFailure}. */ private RetryQueue lease(int attempt) { return new RetryQueue() { + @Override + public boolean retry(T item) { + if (closed || !admit(new Retried<>(item, attempt))) { + dropped.increment(); + return false; + } + return true; + } + @Override @SuppressWarnings("unchecked") public boolean retry(T... items) { boolean all = items.length > 0; for (T item : items) { - if (closed || !admit(new Retried<>(item, attempt))) { - dropped.increment(); - all = false; - } + all &= retry(item); } return all; } 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 index d0bbea8aac1..bfe8bce7964 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/MaxRetries.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MaxRetries.java @@ -9,7 +9,6 @@ public MaxRetries(int maxRetries) { } @Override - @SuppressWarnings("unchecked") 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/RetryQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java index 7ba1fa5c503..380be7b92d6 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java @@ -8,12 +8,22 @@ */ public interface RetryQueue { /** - * Resubmits one or more items in place of the failed item. + * Resubmits the failed item. * - *

Resubmitting a single item reuses the lease the failed item already holds and so cannot fail - * on capacity. Resubmitting several — partitioning failed work into smaller pieces — needs the - * additional slots, and is a no-op returning {@code false} if they cannot be reserved; the - * original item stays leased and is retried later. + *

Reuses the lease the failed item already holds and so cannot fail on capacity. This is the + * overload every ordinary strategy wants: it resubmits without allocating the array the varargs + * form needs. + * + * @return whether the item was resubmitted + */ + boolean retry(T item); + + /** + * Resubmits several items in place of the failed item. + * + *

Partitioning failed work into smaller pieces needs slots beyond the one the failed item + * holds, and is a no-op returning {@code false} if they cannot be reserved; the original item + * stays leased and is retried later. * * @return whether the items were resubmitted */ 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 index 3e712af32c3..50cbedfabdf 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -205,6 +205,31 @@ void unboundedQueueStillCloses() { assertFalse(queue.tryPut("a")); } + @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.process( + 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); + assertEquals(0, queue.dropped(), "partitioned work is not lost"); + } + private static List drain(WorkQueue queue) { List consumed = new ArrayList<>(); while (queue.process(consumed::add)) { From 09dcab01b05a1415e456fb200b6a308847ac54d3 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 22:34:50 -0400 Subject: [PATCH 07/48] Let a consumer failure propagate when no RetryStrategy is given process(consumer) caught Throwable and counted a silent drop, so a caller converting an existing drain loop lost whatever error handling it already had, and had to pass a do-nothing RetryStrategy to get it back. A queue should not be the arbiter of an error policy it was never handed. Without a strategy the throw now travels out to the caller's frame. With one, the strategy owns the failure exactly as before. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 21 ++++++++----- .../java/datadog/common/queue/WorkQueue.java | 17 ++++++++-- .../common/queue/WorkQueueContractTest.java | 31 +++++++++++++++++-- 3 files changed, 57 insertions(+), 12 deletions(-) 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 index 56e8367f6b1..dc7c2e07a17 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -154,6 +154,17 @@ private void consume( item = (T) raw; attempt = 0; } + if (retryStrategy == 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); @@ -161,13 +172,9 @@ private void consume( biConsumer.accept(context, item); } } catch (Throwable failure) { - onFailure(item, attempt + 1, failure, retryStrategy); - } - } - - private void onFailure(T item, int attempt, Throwable failure, RetryStrategy retryStrategy) { - if (retryStrategy == null || !retryStrategy.onFailure(item, attempt, failure, lease(attempt))) { - dropped.increment(); + if (!retryStrategy.onFailure(item, attempt + 1, failure, lease(attempt + 1))) { + dropped.increment(); + } } } 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 index d0c3658097b..23e2940dd46 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -19,7 +19,9 @@ * *

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. + * loop needs, and says nothing about whether the consumer succeeded. A consumer that throws throws + * out of {@code process} unless a {@link RetryStrategy} was supplied to handle it — the queue takes + * no view on failure it was not given one for, and never logs. */ public interface WorkQueue { @@ -54,21 +56,31 @@ public interface WorkQueue { Collection tryPut(Collection elements); /** + * 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 process(Consumer consumer, RetryStrategy retryStrategy); /** + * 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 process( @@ -77,7 +89,8 @@ boolean process( int size(); /** - * @return how many elements have been rejected over this queue's lifetime + * @return how many elements have been rejected on admission, or abandoned by a {@link + * RetryStrategy}, over this queue's lifetime */ long dropped(); 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 index 50cbedfabdf..d157307632d 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.ArrayList; @@ -89,17 +90,41 @@ void processReportsWhetherThereWasWork(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.dropped(), "a failure the caller sees is not a silent drop"); + 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.process( item -> { throw new IllegalStateException("boom"); - }), + }, + giveUp), "the return value reports work found, not consumer success"); - assertEquals(1, queue.dropped(), "an unretried failure loses the item"); + assertEquals(1, queue.dropped(), "an abandoned item is counted"); } @ParameterizedTest(name = "{0}") From f3c1a185fb21c1e66dfa16edb3abcf80cecc66ba Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 22:48:11 -0400 Subject: [PATCH 08/48] Add tryReserve as an escape hatch for callers that cannot use a Producer Some callers must do work between claiming a place and filling it, and cannot express admission as a producer callback. tryReserve gives them a Reservation: the place is claimed where it was taken and keeps its position, so a rejected element still is never built. Only the MPSC backing offers it. Holding a place open relies on the consumer finding the queue empty until the place is ready; with several consumers one of them takes the unfilled place instead and can only spin on it, so a single thread that reserved and then drained would wait on itself. The multi-consumer backings throw rather than deadlock. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 23 +++++ .../datadog/common/queue/LinkedWorkQueue.java | 16 +++- .../datadog/common/queue/MpscWorkQueue.java | 47 +++++++++- .../datadog/common/queue/Reservation.java | 24 +++++ .../main/java/datadog/common/queue/Slot.java | 37 ++++++++ .../java/datadog/common/queue/WorkQueue.java | 18 ++++ .../common/queue/MpscWorkQueueStressTest.java | 94 +++++++++++++++++++ .../common/queue/WorkQueueContractTest.java | 72 ++++++++++++++ 8 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/Reservation.java create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/Slot.java 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 index dc7c2e07a17..b162df1d6f9 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -57,6 +57,16 @@ private static final class Retried { /** * @return the next stored object, or {@code null} if there was none */ + /** + * Claims a place and stores a {@link Slot} in it, for the backings that can hold one open. + * + * @return the slot, or {@code null} if no place could be claimed + */ + Slot reserve() { + throw new UnsupportedOperationException( + getClass().getSimpleName() + " has several consumers and cannot hold a place open"); + } + abstract Object take(); abstract void discardAll(); @@ -106,6 +116,19 @@ public Collection tryPut(Collection elements) { return rejected == null ? emptyList() : rejected; } + @Override + public Reservation tryReserve() { + if (closed) { + dropped.increment(); + return null; + } + Slot slot = reserve(); + if (slot == null) { + dropped.increment(); + } + return slot; + } + @Override public boolean process(Consumer consumer) { return process(consumer, (RetryStrategy) null); diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java index 009b553b949..d368baedf4c 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java @@ -13,6 +13,12 @@ * queue's per-element node, so it does not deliver the allocation win; prefer {@link * MpscWorkQueue}. * + *

Reservations are not available here. Holding a place open needs the consumer to be able to see + * that the place is not ready yet and simply find the queue empty; with several consumers, one of + * them takes the place instead and has nothing to do but spin until it is filled — a single thread + * that reserves and then drains would wait on itself forever. {@link MpscWorkQueue} has one + * consumer and can offer the hatch safely. + * *

The size counter is not merely bookkeeping. It is what makes the bound enforceable and {@link * #size()} constant-time, replacing the hand-rolled cap plus O(n) {@code ConcurrentLinkedQueue * .size()} walk that call sites otherwise pay on every admission. @@ -32,7 +38,7 @@ final class LinkedWorkQueue extends BaseWorkQueue { @Override boolean admit(Object element) { - if (!reserve()) { + if (!claimPlace()) { return false; } queue.offer(element); @@ -41,7 +47,7 @@ boolean admit(Object element) { @Override boolean admit(C context, ContextualProducer producer) { - if (!reserve()) { + if (!claimPlace()) { return false; } T element; @@ -55,7 +61,7 @@ boolean admit(C context, ContextualProducer producer return true; } - private boolean reserve() { + private boolean claimPlace() { if (capacity == Integer.MAX_VALUE) { size.incrementAndGet(); return true; @@ -72,6 +78,10 @@ private boolean reserve() { @Override Object take() { + return poll(); + } + + private Object poll() { Object element = queue.poll(); if (element != null) { size.decrementAndGet(); 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 index 0fbc85a6962..ab479999e9f 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java @@ -34,8 +34,26 @@ public Object get() { } } + /** Creates the slot inside the claimed place, and hands it back to the reserving thread. */ + private static final class SlotSupplier implements MessagePassingQueue.Supplier { + Slot slot; + + @Override + public Object get() { + slot = new Slot<>(); + return slot; + } + } + private final MessagePassingQueue queue; + /** + * Set before the first {@link Slot} can reach the array, and never cleared. A queue whose caller + * never reserves keeps the plain consumption path; one that has reserved even once pays a peek + * and a type test per item forever, which is the price of not taxing every other call site. + */ + private volatile boolean reservations; + MpscWorkQueue(int requestedCapacity) { this.queue = Queues.mpscArrayQueue(requestedCapacity); } @@ -50,9 +68,36 @@ boolean admit(C context, ContextualProducer producer return queue.fill(new ProducingSupplier<>(context, producer), 1) == 1; } + @Override + Slot reserve() { + // Set first: a slot must never reach the array before the consumer knows to expect one. + reservations = true; + SlotSupplier supplier = new SlotSupplier<>(); + return queue.fill(supplier, 1) == 1 ? supplier.slot : null; + } + @Override Object take() { - return queue.poll(); + if (!reservations) { + return queue.poll(); + } + for (; ; ) { + Object head = queue.relaxedPeek(); + if (!(head instanceof Slot)) { + // Either empty, or an ordinary element whose place was never reserved. + return head == null ? null : queue.poll(); + } + Object element = ((Slot) head).element(); + if (element == null) { + // Still being built. The place is claimed, so there is nothing behind it to take either. + return null; + } + queue.poll(); + if (element != Slot.RELEASED) { + return element; + } + // Abandoned without ever being filled: skip it and look at what is behind it. + } } @Override 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..444319614c6 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/Reservation.java @@ -0,0 +1,24 @@ +package datadog.common.queue; + +/** + * A claimed place in a {@link WorkQueue}, for the rare caller that must do work between claiming + * and filling and so cannot express its admission as a {@link Producer}. + * + *

The place is claimed where the reservation was taken, and a consumer will not see past it + * until it is filled or released — so an open reservation stalls the consumer, and one that is + * never closed stalls it forever. Take one only in try-with-resources, hold it for as long as it + * takes to build one element, and prefer the producer forms of {@code tryPut}, which cannot be + * leaked. + */ +public interface Reservation extends AutoCloseable { + + /** + * Publishes {@code element} into the claimed place. The place is already claimed, so this cannot + * fail and cannot be rejected. + */ + void fill(T element); + + /** Releases the place if it was never filled. Filling first makes this a no-op. */ + @Override + void close(); +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/Slot.java b/utils/queue-utils/src/main/java/datadog/common/queue/Slot.java new file mode 100644 index 00000000000..a35a1fdd388 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/Slot.java @@ -0,0 +1,37 @@ +package datadog.common.queue; + +/** + * The placeholder a {@link Reservation} leaves in the backing store, so the claimed place keeps its + * position in the queue while the caller builds the element that goes in it. + * + *

The consumer distinguishes a slot from an ordinary element by type, which is why every backing + * stores {@code Object} rather than {@code T}. + */ +final class Slot implements Reservation { + + /** Distinguishes "released without ever being filled" from "still open". */ + static final Object RELEASED = new Object(); + + /** Written by the reserving thread, read by the consumer; null while the place is still open. */ + private volatile Object element; + + Object element() { + return element; + } + + @Override + public void fill(T element) { + if (element == null) { + throw new NullPointerException("a queue cannot hold null"); + } + this.element = element; + } + + @Override + public void close() { + // Only the reserving thread calls fill and close, so a plain check orders them correctly. + if (element == null) { + element = RELEASED; + } + } +} 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 index 23e2940dd46..70aaf45c418 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -55,6 +55,24 @@ public interface WorkQueue { */ Collection tryPut(Collection elements); + /** + * Claims a place without supplying its element, for a caller whose work between claiming and + * filling cannot be expressed as a {@link Producer}. + * + *

This is the escape hatch, and it is a sharper tool than the {@code tryPut} family: the + * consumer cannot see past an open reservation, so one that is not promptly filled or closed + * stalls it. Use try-with-resources. + * + *

Only the single-consumer backing offers it. Holding a place open depends on the consumer + * being able to find the queue empty until the place is ready; where several consumers share a + * queue one of them takes the unfilled place instead and can only spin on it, so those backings + * refuse rather than deadlock. + * + * @return the claimed place, or {@code null} if there was no room + * @throws UnsupportedOperationException if this queue has more than one consumer + */ + Reservation tryReserve(); + /** * Consumes one item, if there is one. A throwing consumer propagates. * 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 index 5015353cd4a..f00e7375970 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/MpscWorkQueueStressTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/MpscWorkQueueStressTest.java @@ -156,6 +156,100 @@ void neverInvokesProducerWhileFull() throws Exception { "every rejected admission is counted"); } + /** + * 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 != null) { + 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(); 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 index d157307632d..c83514b8e97 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -2,6 +2,8 @@ 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.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -255,6 +257,76 @@ void retryCanPartitionFailedWorkIntoSeveralItems( assertEquals(0, queue.dropped(), "partitioned work is not lost"); } + // Reservations are the single-consumer backing's alone: see WorkQueue#tryReserve. + + @org.junit.jupiter.api.Test + void reservationHoldsItsPlaceUntilFilled() { + WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); + try (Reservation place = queue.tryReserve()) { + assertNotNull(place); + assertTrue(queue.tryPut("behind"), "the rest of the queue stays open for admission"); + assertFalse(queue.process(item -> {}), "the consumer cannot see past an open reservation"); + place.fill("reserved"); + } + assertEquals(Arrays.asList("reserved", "behind"), drain(queue)); + } + + /** The stall an open reservation causes is why it is an escape hatch and not the default. */ + @org.junit.jupiter.api.Test + void abandonedReservationReleasesTheConsumer() { + WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); + Reservation place = queue.tryReserve(); + assertNotNull(place); + queue.tryPut("behind"); + assertFalse(queue.process(item -> {})); + + place.close(); + + assertEquals(Arrays.asList("behind"), drain(queue), "the abandoned place is skipped, not held"); + assertEquals(0, queue.dropped(), "abandoning a place the caller claimed is not a rejection"); + } + + @org.junit.jupiter.api.Test + void reserveFailsWhenThereIsNoRoom() { + WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i)); + } + assertNull(queue.tryReserve()); + assertEquals(1, queue.dropped(), "a place that could not be claimed counts like a rejection"); + } + + @org.junit.jupiter.api.Test + void reserveFailsOnceClosed() { + WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); + queue.close(); + assertNull(queue.tryReserve()); + } + + @org.junit.jupiter.api.Test + void filledReservationsInterleaveWithOrdinaryAdmission() { + WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); + queue.tryPut("first"); + try (Reservation place = queue.tryReserve()) { + place.fill("second"); + } + queue.tryPut("third"); + assertEquals(Arrays.asList("first", "second", "third"), drain(queue)); + } + + /** + * A multi-consumer queue refuses the hatch rather than letting a consumer spin on a held place. + */ + @org.junit.jupiter.api.Test + void multiConsumerQueuesRefuseToReserve() { + assertThrows( + UnsupportedOperationException.class, + () -> WorkQueues.createMpmcQueue(CAPACITY).tryReserve()); + assertThrows( + UnsupportedOperationException.class, + () -> WorkQueues.createUnboundedMpmcQueue().tryReserve()); + } + private static List drain(WorkQueue queue) { List consumed = new ArrayList<>(); while (queue.process(consumed::add)) { From 8fd67a001e1df2d3eb7472b9b5f968b0057ae3a8 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 22:55:16 -0400 Subject: [PATCH 09/48] Let the linked backing reserve capacity without holding a position A reservation claims capacity, and only the array backing needs to claim a position to do it. The linked queue has no slot to hold, so reserving is just the size counter it already keeps and filling is an ordinary offer: no placeholder, no consumer stall, nothing for a second consumer to trip over. The multi-consumer refusal goes away with it. The order a filled element lands in differs between the two, and an abandoned array slot returns its capacity as the consumer passes over it rather than at close. Both are now stated on the API and pinned by tests. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 15 ++- .../datadog/common/queue/LinkedWorkQueue.java | 32 ++++++ .../datadog/common/queue/MpscWorkQueue.java | 2 +- .../datadog/common/queue/Reservation.java | 17 ++-- .../java/datadog/common/queue/WorkQueue.java | 12 +-- .../common/queue/WorkQueueContractTest.java | 99 ++++++++++++------- 6 files changed, 121 insertions(+), 56 deletions(-) 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 index b162df1d6f9..6cd10984ea0 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -58,14 +58,11 @@ private static final class Retried { * @return the next stored object, or {@code null} if there was none */ /** - * Claims a place and stores a {@link Slot} in it, for the backings that can hold one open. + * Claims capacity for an element that does not exist yet. * - * @return the slot, or {@code null} if no place could be claimed + * @return the reservation, or {@code null} if no capacity could be claimed */ - Slot reserve() { - throw new UnsupportedOperationException( - getClass().getSimpleName() + " has several consumers and cannot hold a place open"); - } + abstract Reservation reserve(); abstract Object take(); @@ -122,11 +119,11 @@ public Reservation tryReserve() { dropped.increment(); return null; } - Slot slot = reserve(); - if (slot == null) { + Reservation reservation = reserve(); + if (reservation == null) { dropped.increment(); } - return slot; + return reservation; } @Override diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java index d368baedf4c..61f6220c8ae 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java @@ -76,6 +76,38 @@ private boolean claimPlace() { return true; } + /** + * Capacity claimed ahead of the element that will use it. Filling can only ever offer, because + * the room was already taken; abandoning gives the room back. + */ + private final class LinkedReservation implements Reservation { + private boolean done; + + @Override + public void fill(T element) { + if (element == null) { + throw new NullPointerException("a queue cannot hold null"); + } + if (!done) { + done = true; + queue.offer(element); + } + } + + @Override + public void close() { + if (!done) { + done = true; + size.decrementAndGet(); + } + } + } + + @Override + Reservation reserve() { + return claimPlace() ? new LinkedReservation() : null; + } + @Override Object take() { return 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 index ab479999e9f..0c6ff3e4ee8 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java @@ -69,7 +69,7 @@ boolean admit(C context, ContextualProducer producer } @Override - Slot reserve() { + Reservation reserve() { // Set first: a slot must never reach the array before the consumer knows to expect one. reservations = true; SlotSupplier supplier = new SlotSupplier<>(); 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 index 444319614c6..791061bf78b 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/Reservation.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/Reservation.java @@ -4,11 +4,11 @@ * A claimed place in a {@link WorkQueue}, for the rare caller that must do work between claiming * and filling and so cannot express its admission as a {@link Producer}. * - *

The place is claimed where the reservation was taken, and a consumer will not see past it - * until it is filled or released — so an open reservation stalls the consumer, and one that is - * never closed stalls it forever. Take one only in try-with-resources, hold it for as long as it - * takes to build one element, and prefer the producer forms of {@code tryPut}, which cannot be - * leaked. + *

Capacity is claimed when the reservation is taken and held until it is filled or released, so + * one that is never closed leaks capacity, and on an array-backed queue — where the claim is a slot + * the consumer cannot see past — stalls the consumer as well. Take one only in try-with-resources, + * hold it for as long as it takes to build one element, and prefer the producer forms of {@code + * tryPut}, which cannot be leaked. */ public interface Reservation extends AutoCloseable { @@ -18,7 +18,12 @@ public interface Reservation extends AutoCloseable { */ void fill(T element); - /** Releases the place if it was never filled. Filling first makes this a no-op. */ + /** + * Releases the place if it was never filled. Filling first makes this a no-op. + * + *

Nothing is ever consumed for a released place. Where the claim was a slot, the capacity + * comes back as the consumer passes over it rather than the instant it is released. + */ @Override void close(); } 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 index 70aaf45c418..8a65223c633 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -63,13 +63,13 @@ public interface WorkQueue { * consumer cannot see past an open reservation, so one that is not promptly filled or closed * stalls it. Use try-with-resources. * - *

Only the single-consumer backing offers it. Holding a place open depends on the consumer - * being able to find the queue empty until the place is ready; where several consumers share a - * queue one of them takes the unfilled place instead and can only spin on it, so those backings - * refuse rather than deadlock. + *

What is reserved is capacity — {@link Reservation#fill} cannot be rejected. Whether the + * element also keeps the position it was claimed at depends on the backing: an array-backed queue + * claims a slot, and so holds the order, at the cost of a consumer that cannot see past it until + * it is filled; a linked queue has no slot to hold and joins the element at the tail when it is + * filled, so nothing stalls and the order is the fill order. * - * @return the claimed place, or {@code null} if there was no room - * @throws UnsupportedOperationException if this queue has more than one consumer + * @return the claimed capacity, or {@code null} if there was none to claim */ Reservation tryReserve(); 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 index c83514b8e97..be5ba395862 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -257,38 +257,49 @@ void retryCanPartitionFailedWorkIntoSeveralItems( assertEquals(0, queue.dropped(), "partitioned work is not lost"); } - // Reservations are the single-consumer backing's alone: see WorkQueue#tryReserve. + // A reservation claims capacity on every backing; only the array backing also holds position. - @org.junit.jupiter.api.Test - void reservationHoldsItsPlaceUntilFilled() { - WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void reservationClaimsCapacityUpFront(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); try (Reservation place = queue.tryReserve()) { assertNotNull(place); - assertTrue(queue.tryPut("behind"), "the rest of the queue stays open for admission"); - assertFalse(queue.process(item -> {}), "the consumer cannot see past an open reservation"); + 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"); } - assertEquals(Arrays.asList("reserved", "behind"), drain(queue)); + assertTrue(drain(queue).contains("reserved"), "filling a claimed place cannot be rejected"); } - /** The stall an open reservation causes is why it is an escape hatch and not the default. */ - @org.junit.jupiter.api.Test - void abandonedReservationReleasesTheConsumer() { - WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void abandonedReservationYieldsNothingAndGivesTheCapacityBack( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); Reservation place = queue.tryReserve(); assertNotNull(place); - queue.tryPut("behind"); - assertFalse(queue.process(item -> {})); - place.close(); - assertEquals(Arrays.asList("behind"), drain(queue), "the abandoned place is skipped, not held"); + // 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(drain(queue).isEmpty(), "an abandoned place produces no element"); + assertEquals(0, queue.size()); assertEquals(0, queue.dropped(), "abandoning a place the caller claimed is not a rejection"); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i), "the abandoned capacity is usable again"); + } + assertEquals(CAPACITY, drain(queue).size()); } - @org.junit.jupiter.api.Test - void reserveFailsWhenThereIsNoRoom() { - WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); + @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)); } @@ -296,35 +307,55 @@ void reserveFailsWhenThereIsNoRoom() { assertEquals(1, queue.dropped(), "a place that could not be claimed counts like a rejection"); } - @org.junit.jupiter.api.Test - void reserveFailsOnceClosed() { - WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void reserveFailsOnceClosed(String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); queue.close(); assertNull(queue.tryReserve()); } + /** The array backing claims a slot, so the element keeps the position it was reserved at. */ @org.junit.jupiter.api.Test - void filledReservationsInterleaveWithOrdinaryAdmission() { + void arrayBackedReservationHoldsItsPosition() { WorkQueue queue = WorkQueues.createMpscQueue(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"); + assertTrue(queue.process(consumed::add), "what was admitted before the claim is unaffected"); + assertFalse( + queue.process(consumed::add), + "holding a position means the consumer cannot see past it, even for what is behind"); place.fill("second"); } - queue.tryPut("third"); - assertEquals(Arrays.asList("first", "second", "third"), drain(queue)); + consumed.addAll(drain(queue)); + assertEquals(Arrays.asList("first", "second", "behind"), consumed); + } + + /** The linked backing has no slot to hold, so nothing is held in front of the consumer. */ + @org.junit.jupiter.api.Test + void linkedReservationDoesNotStallTheConsumer() { + WorkQueue queue = WorkQueues.createUnboundedMpmcQueue(); + try (Reservation place = queue.tryReserve()) { + assertTrue(queue.tryPut("behind")); + assertTrue(queue.process(item -> {}), "an open reservation holds nothing back"); + place.fill("filled late"); + } + assertEquals(Arrays.asList("filled late"), drain(queue), "the order is the fill order"); } - /** - * A multi-consumer queue refuses the hatch rather than letting a consumer spin on a held place. - */ @org.junit.jupiter.api.Test - void multiConsumerQueuesRefuseToReserve() { - assertThrows( - UnsupportedOperationException.class, - () -> WorkQueues.createMpmcQueue(CAPACITY).tryReserve()); - assertThrows( - UnsupportedOperationException.class, - () -> WorkQueues.createUnboundedMpmcQueue().tryReserve()); + 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()); + assertEquals(0, queue.dropped()); } private static List drain(WorkQueue queue) { From 454c309b7679d8a669601b21554151b76eb4db39 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 23:02:40 -0400 Subject: [PATCH 10/48] Bound the linked backing with a permit counter The linked backing tracked occupancy and claimed a place with a compare-and-set loop, so admission paid a retry exactly when it was most contended, and an unbounded queue had to be branched around the cap. Track places still available instead. Admission spends one, consumption returns one, and the bound is a comparison against zero: one atomic add on the success path, a second only where the admission was going to be rejected anyway, and no loop. An unbounded queue is seeded with Integer.MAX_VALUE and takes the same path as any other, since no backlog can exhaust it. The cap stays exact. What becomes approximate is who is 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 short of full. That only happens where the caller is already dropping work. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/LinkedWorkQueue.java | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java index 61f6220c8ae..d2b5f3b3f8a 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java @@ -21,12 +21,22 @@ * *

The size counter is not merely bookkeeping. It is what makes the bound enforceable and {@link * #size()} constant-time, replacing the hand-rolled cap plus O(n) {@code ConcurrentLinkedQueue - * .size()} walk that call sites otherwise pay on every admission. + * .size()} walk that call sites otherwise pay on every admission. It costs one atomic add per + * admission and one per consumption; a call site migrating off an uncapped {@code + * ConcurrentLinkedQueue} gets a bound for roughly what its old size check cost. */ final class LinkedWorkQueue extends BaseWorkQueue { private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); - private final AtomicInteger size = new AtomicInteger(); + + /** + * Places still available, not places used. Admission spends one and consumption returns it, so + * the bound is 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 AtomicInteger available; + private final int capacity; /** @@ -34,6 +44,7 @@ final class LinkedWorkQueue extends BaseWorkQueue { */ LinkedWorkQueue(int capacity) { this.capacity = capacity; + this.available = new AtomicInteger(capacity); } @Override @@ -54,26 +65,31 @@ boolean admit(C context, ContextualProducer producer try { element = producer.produce(context); } catch (Throwable t) { - size.decrementAndGet(); + available.incrementAndGet(); throw t; } queue.offer(element); return true; } + /** + * 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. + * + *

The cap itself is exact: the queue never holds more than {@code capacity} elements. 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 (capacity == Integer.MAX_VALUE) { - size.incrementAndGet(); + if (available.decrementAndGet() >= 0) { return true; } - int current; - do { - current = size.get(); - if (current >= capacity) { - return false; - } - } while (!size.compareAndSet(current, current + 1)); - return true; + available.incrementAndGet(); + return false; } /** @@ -98,7 +114,7 @@ public void fill(T element) { public void close() { if (!done) { done = true; - size.decrementAndGet(); + available.incrementAndGet(); } } } @@ -116,7 +132,7 @@ Object take() { private Object poll() { Object element = queue.poll(); if (element != null) { - size.decrementAndGet(); + available.incrementAndGet(); } return element; } @@ -130,6 +146,7 @@ void discardAll() { @Override public int size() { - return size.get(); + // Claimants at the boundary can transiently drive the count below zero before backing out. + return Math.max(0, capacity - available.get()); } } From 7f627fd88369e8ab630fb4c71ba29b59e6b801f1 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 23:05:52 -0400 Subject: [PATCH 11/48] Describe linked-backing reservations, which are no longer unsupported Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/LinkedWorkQueue.java | 20 +++++++++---------- .../java/datadog/common/queue/WorkQueues.java | 5 +++-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java index d2b5f3b3f8a..4d896b31d2b 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java @@ -13,17 +13,17 @@ * queue's per-element node, so it does not deliver the allocation win; prefer {@link * MpscWorkQueue}. * - *

Reservations are not available here. Holding a place open needs the consumer to be able to see - * that the place is not ready yet and simply find the queue empty; with several consumers, one of - * them takes the place instead and has nothing to do but spin until it is filled — a single thread - * that reserves and then drains would wait on itself forever. {@link MpscWorkQueue} has one - * consumer and can offer the hatch safely. + *

A reservation here claims capacity and nothing else. There is no slot to hold, so the element + * joins at the tail when it is filled and the queue keeps fill order rather than claim order — and, + * because no place is ever open in the queue itself, no consumer can find one it has to wait on. + * {@link MpscWorkQueue} pays for claim order with a consumer that cannot see past an open + * reservation; this backing does not have that hazard because it does not offer that guarantee. * - *

The size counter is not merely bookkeeping. It is what makes the bound enforceable and {@link - * #size()} constant-time, replacing the hand-rolled cap plus O(n) {@code ConcurrentLinkedQueue - * .size()} walk that call sites otherwise pay on every admission. It costs one atomic add per - * admission and one per consumption; a call site migrating off an uncapped {@code - * ConcurrentLinkedQueue} gets a bound for roughly what its old size check cost. + *

The permit counter is not merely bookkeeping. It is what makes the bound enforceable and + * {@link #size()} constant-time, replacing the hand-rolled cap plus O(n) {@code + * ConcurrentLinkedQueue.size()} walk that call sites otherwise pay on every admission. It costs one + * atomic add per admission and one per consumption; a call site migrating off an uncapped {@code + * ConcurrentLinkedQueue} gets a bound for less than its old size check cost. */ final class LinkedWorkQueue extends BaseWorkQueue { 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 index 3381489e302..63ff73f7ffa 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java @@ -32,8 +32,9 @@ public static WorkQueue createMpscQueue(int requestedCapacity) { * ConcurrentLinkedQueue}. * *

For call sites that need several consumers. It keeps the linked queue's per-element node, so - * it buys the admission and lifecycle contract but not the allocation win — prefer {@link - * #createMpscQueue} where a single consumer is possible. + * it buys the admission and lifecycle contract, an enforceable bound and a constant-time {@link + * WorkQueue#size()}, but not the allocation win — prefer {@link #createMpscQueue} where a single + * consumer is possible. * * @param capacity the bound */ From 061ce469b23e9e363a48d76b2f998a6e60013265 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 23:16:28 -0400 Subject: [PATCH 12/48] Add a batched process that takes an item limit Consumers had only the one-item form, so a drain loop paid a call per item where the backing could have handed over a batch. Add an overload that consumes up to a caller-named limit and returns how many it took, which is both the sleep signal and, when it equals the limit, the hint that there is more waiting. The limit is required. Consume-until-empty has no reason to return against live producers, has no implicit bound at all on an unbounded backing, and would let a retry strategy feed a drain its own output. Naming it also puts the latency knob at the call site, which matters where the consuming thread is shared with other subsystems. A duration overload can follow if a caller needs one. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 29 +++++ .../java/datadog/common/queue/WorkQueue.java | 27 ++++ .../common/queue/WorkQueueContractTest.java | 123 ++++++++++++++++-- 3 files changed, 171 insertions(+), 8 deletions(-) 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 index 6cd10984ea0..c93316dd832 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -157,6 +157,35 @@ public boolean process( return true; } + @Override + public int process(int limit, Consumer consumer) { + return process(limit, consumer, null, null); + } + + @Override + public 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. + consumed++; + consume(raw, consumer, context, biConsumer, null); + } + return consumed; + } + @SuppressWarnings("unchecked") private void consume( Object raw, 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 index 8a65223c633..7fff72b55fa 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -104,6 +104,33 @@ public interface WorkQueue { boolean process( C context, BiConsumer consumer, RetryStrategy retryStrategy); + /** + * 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); + int size(); /** 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 index be5ba395862..07803c75ba6 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; 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; @@ -67,7 +68,7 @@ void doesNotInvokeProducerWhenFull(String name, IntFunction> f void invokesProducerWhenThereIsRoom(String name, IntFunction> factory) { WorkQueue queue = factory.apply(CAPACITY); assertTrue(queue.tryPut("ctx", context -> context + "-built")); - List consumed = drain(queue); + List consumed = consumeAll(queue); assertEquals(Arrays.asList("ctx-built"), consumed); } @@ -187,7 +188,7 @@ void closeStopsAdmissionButKeepsBacklog(String name, IntFunction assertFalse(queue.tryPut("overflow"), "the claimed place is not available to anyone else"); place.fill("reserved"); } - assertTrue(drain(queue).contains("reserved"), "filling a claimed place cannot be rejected"); + assertTrue( + consumeAll(queue).contains("reserved"), "filling a claimed place cannot be rejected"); } @ParameterizedTest(name = "{0}") @@ -287,13 +289,13 @@ void abandonedReservationYieldsNothingAndGivesTheCapacityBack( // 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(drain(queue).isEmpty(), "an abandoned place produces no element"); + assertTrue(consumeAll(queue).isEmpty(), "an abandoned place produces no element"); assertEquals(0, queue.size()); assertEquals(0, queue.dropped(), "abandoning a place the caller claimed is not a rejection"); for (int i = 0; i < CAPACITY; i++) { assertTrue(queue.tryPut("e" + i), "the abandoned capacity is usable again"); } - assertEquals(CAPACITY, drain(queue).size()); + assertEquals(CAPACITY, consumeAll(queue).size()); } @ParameterizedTest(name = "{0}") @@ -329,7 +331,7 @@ void arrayBackedReservationHoldsItsPosition() { "holding a position means the consumer cannot see past it, even for what is behind"); place.fill("second"); } - consumed.addAll(drain(queue)); + consumed.addAll(consumeAll(queue)); assertEquals(Arrays.asList("first", "second", "behind"), consumed); } @@ -342,7 +344,7 @@ void linkedReservationDoesNotStallTheConsumer() { assertTrue(queue.process(item -> {}), "an open reservation holds nothing back"); place.fill("filled late"); } - assertEquals(Arrays.asList("filled late"), drain(queue), "the order is the fill order"); + assertEquals(Arrays.asList("filled late"), consumeAll(queue), "the order is the fill order"); } @org.junit.jupiter.api.Test @@ -358,7 +360,112 @@ void unboundedReservationAlwaysSucceeds() { assertEquals(0, queue.dropped()); } - private static List drain(WorkQueue queue) { + @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"); + assertEquals(0, queue.dropped(), "a failure the caller sees is not a drop"); + } + + @org.junit.jupiter.api.Test + void processStopsAtAnOpenReservation() { + WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); + queue.tryPut("first"); + List consumed = new ArrayList<>(); + + try (Reservation place = queue.tryReserve()) { + queue.tryPut("behind"); + + assertEquals( + 1, + queue.process(10, consumed::add), + "an array-backed reservation holds its position, so the batch ends there"); + assertEquals(Arrays.asList("first"), consumed); + + place.fill("reserved"); + } + + assertEquals(2, queue.process(10, consumed::add)); + assertEquals(Arrays.asList("first", "reserved", "behind"), consumed); + } + + private static List consumeAll(WorkQueue queue) { List consumed = new ArrayList<>(); while (queue.process(consumed::add)) { // drain From 19d8d023737da50d4e288bb70a777b29739433ab Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 23:38:01 -0400 Subject: [PATCH 13/48] Let a producer take a second context A producer receives only the item, so a call site with a value hoisted out of its loop - a schema, a clock reading, a per-batch buffer - had no way to carry it: it had to capture per iteration, cache a binding that can go stale, or re-read the field per item and lose the hoist. Add a two-context producer and the matching tryPut. The producer stays a non-capturing bound-once field and the hoist stays visible where it happens. The ladder stops at two. 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. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 10 ++++++ .../common/queue/BiContextualProducer.java | 19 +++++++++++ .../datadog/common/queue/LinkedWorkQueue.java | 17 ++++++++++ .../datadog/common/queue/MpscWorkQueue.java | 26 ++++++++++++++ .../java/datadog/common/queue/WorkQueue.java | 9 +++++ .../common/queue/WorkQueueContractTest.java | 34 +++++++++++++++++++ 6 files changed, 115 insertions(+) create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/BiContextualProducer.java 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 index c93316dd832..5ce2cbca8b6 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -54,6 +54,10 @@ private static final class Retried { */ abstract boolean admit(C context, ContextualProducer producer); + /** Claims a slot and only then invokes the two-context producer. */ + abstract boolean admit( + C1 first, C2 second, BiContextualProducer producer); + /** * @return the next stored object, or {@code null} if there was none */ @@ -84,6 +88,12 @@ public boolean tryPut(C context, ContextualProducer return record(!closed && admit(context, producer)); } + @Override + public boolean tryPut( + C1 first, C2 second, BiContextualProducer producer) { + return record(!closed && admit(first, second, producer)); + } + @Override @SafeVarargs public final Collection tryPutBatch(T... elements) { 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..9f7980ae906 --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BiContextualProducer.java @@ -0,0 +1,19 @@ +package datadog.common.queue; + +/** + * 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. + */ +@FunctionalInterface +public interface BiContextualProducer { + T produce(C1 first, C2 second); +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java index 4d896b31d2b..6395a2f2f7c 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java @@ -72,6 +72,23 @@ boolean admit(C context, ContextualProducer producer return true; } + @Override + boolean admit( + C1 first, C2 second, BiContextualProducer producer) { + if (!claimPlace()) { + return false; + } + T element; + try { + element = producer.produce(first, second); + } catch (Throwable t) { + available.incrementAndGet(); + throw t; + } + queue.offer(element); + return true; + } + /** * 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 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 index 0c6ff3e4ee8..1cbb5be6acb 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java @@ -34,6 +34,26 @@ public Object get() { } } + /** The two-context form of {@link ProducingSupplier}, with the same escape-free lifetime. */ + private static final class BiProducingSupplier + implements MessagePassingQueue.Supplier { + private final C1 first; + private final C2 second; + private final BiContextualProducer producer; + + BiProducingSupplier( + C1 first, C2 second, BiContextualProducer producer) { + this.first = first; + this.second = second; + this.producer = producer; + } + + @Override + public Object get() { + return producer.produce(first, second); + } + } + /** Creates the slot inside the claimed place, and hands it back to the reserving thread. */ private static final class SlotSupplier implements MessagePassingQueue.Supplier { Slot slot; @@ -68,6 +88,12 @@ boolean admit(C context, ContextualProducer producer return queue.fill(new ProducingSupplier<>(context, producer), 1) == 1; } + @Override + boolean admit( + C1 first, C2 second, BiContextualProducer producer) { + return queue.fill(new BiProducingSupplier<>(first, second, producer), 1) == 1; + } + @Override Reservation reserve() { // Set first: a slot must never reach the array before the consumer knows to expect one. 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 index 7fff72b55fa..591264acb91 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -44,6 +44,15 @@ public interface WorkQueue { */ boolean tryPut(C context, ContextualProducer producer); + /** + * Admits an element derived from two contexts, constructing it only once a slot is reserved. + * + * @return whether the element was admitted + * @see BiContextualProducer + */ + boolean tryPut( + C1 first, C2 second, BiContextualProducer producer); + /** * @return the elements that were not admitted, empty if all were */ 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 index 07803c75ba6..f4c739d7522 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -465,6 +465,40 @@ void processStopsAtAnOpenReservation() { assertEquals(Arrays.asList("first", "reserved", "behind"), consumed); } + @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"); + assertEquals(1, queue.dropped()); + } + private static List consumeAll(WorkQueue queue) { List consumed = new ArrayList<>(); while (queue.process(consumed::add)) { From 23a7b1fff0d793d78e4c58951811daf757e1ce48 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 23:55:20 -0400 Subject: [PATCH 14/48] Bound every backing with the same permit counter The counter that bounded the linked backing moves up into BaseWorkQueue and now bounds the array backing too. Both subclasses shrink to store/retrieve, and Slot -- the placeholder that let an array-backed reservation hold its position -- is gone. A reservation now claims capacity and never a position, on every backing. Nothing is held open in front of a consumer, so a reservation can no longer stall one, and a thread may safely reserve and consume. The costs, taken knowingly: one atomic add per admission and one per consumption on a ring that could have leaned on its own bound, order is fill order rather than claim order, and an abandoned reservation leaks capacity quietly instead of stalling loudly. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 174 +++++++++++++++--- .../datadog/common/queue/LinkedWorkQueue.java | 146 +-------------- .../datadog/common/queue/MpscWorkQueue.java | 141 +++----------- .../datadog/common/queue/Reservation.java | 20 +- .../java/datadog/common/queue/RetryQueue.java | 14 +- .../main/java/datadog/common/queue/Slot.java | 37 ---- .../java/datadog/common/queue/WorkQueue.java | 13 +- .../common/queue/WorkQueueContractTest.java | 65 ++++--- 8 files changed, 236 insertions(+), 374 deletions(-) delete mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/Slot.java 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 index 5ce2cbca8b6..03bba1a5af8 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -5,18 +5,25 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.LongAdder; import java.util.function.BiConsumer; import java.util.function.Consumer; /** - * Everything a {@link WorkQueue} does that does not depend on how elements are stored: admission - * bookkeeping, the closed flag, drop counting, and the consume-and-maybe-retry cycle. + * Everything a {@link WorkQueue} does that does not depend on how elements are stored: the bound, + * admission, reservations, the closed flag, drop counting, and the consume-and-maybe-retry cycle. * - *

Subclasses supply four storage primitives. {@link #admit(Object)} and {@link #admit(Object, - * ContextualProducer)} must both claim a slot before storing anything, and the producing form must - * not invoke the producer unless the claim succeeded — that is the contract this whole API exists - * to provide. + *

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 { @@ -41,36 +48,153 @@ private static final class Retried { private volatile boolean closed; /** - * Stores an already-built element, claiming a slot first. - * - * @return whether a slot was claimed and the element stored + * Places still available, not places used. 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. */ - abstract boolean admit(Object element); + private final AtomicInteger available; + + private final int capacity; + + BaseWorkQueue(int capacity) { + this.capacity = capacity; + this.available = new AtomicInteger(capacity); + } /** - * Claims a slot and only then invokes the producer to build the element. + * 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. * - * @return whether a slot was claimed and the element stored + * @return whether the element was stored */ - abstract boolean admit(C context, ContextualProducer producer); - - /** Claims a slot and only then invokes the two-context producer. */ - abstract boolean admit( - C1 first, C2 second, BiContextualProducer producer); + abstract boolean store(Object element); /** * @return the next stored object, or {@code null} if there was none */ + abstract Object retrieve(); + /** - * Claims capacity for an element that does not exist yet. + * 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. * - * @return the reservation, or {@code null} if no capacity could be claimed + *

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. */ - abstract Reservation reserve(); + private boolean claimPlace() { + if (available.decrementAndGet() >= 0) { + return true; + } + available.incrementAndGet(); + return false; + } + + private void releasePlace() { + available.incrementAndGet(); + } - abstract Object take(); + private boolean admit(Object element) { + if (!claimPlace()) { + return false; + } + if (store(element)) { + return true; + } + releasePlace(); + return false; + } - abstract void discardAll(); + private boolean admit(C context, ContextualProducer producer) { + if (!claimPlace()) { + return false; + } + T element; + try { + element = producer.produce(context); + } catch (Throwable t) { + releasePlace(); + throw t; + } + return storeOrRelease(element); + } + + private boolean admit( + C1 first, C2 second, BiContextualProducer producer) { + if (!claimPlace()) { + return false; + } + T element; + try { + element = producer.produce(first, second); + } catch (Throwable t) { + releasePlace(); + throw t; + } + return storeOrRelease(element); + } + + private boolean storeOrRelease(T element) { + if (element != null && 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. + */ + private final class PlaceReservation implements Reservation { + private boolean done; + + @Override + public void fill(T element) { + if (element == null) { + throw new NullPointerException("a queue cannot hold null"); + } + if (!done) { + done = true; + store(element); + } + } + + @Override + public void close() { + // Only the reserving thread fills or closes, so a plain flag orders the two correctly. + if (!done) { + done = true; + releasePlace(); + } + } + } + + private Object take() { + Object element = retrieve(); + if (element != null) { + releasePlace(); + } + return element; + } + + private void discardAll() { + while (take() != null) { + // give every place back as it goes + } + } + + @Override + public int size() { + // Claimants at the boundary can transiently drive the count below zero before backing out. + return Math.max(0, capacity - available.get()); + } @Override public boolean tryPut(T element) { @@ -129,11 +253,11 @@ public Reservation tryReserve() { dropped.increment(); return null; } - Reservation reservation = reserve(); - if (reservation == null) { + if (!claimPlace()) { dropped.increment(); + return null; } - return reservation; + return new PlaceReservation(); } @Override diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java index 6395a2f2f7c..0cd4cec2808 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java @@ -1,7 +1,6 @@ package datadog.common.queue; import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.atomic.AtomicInteger; /** * A {@link WorkQueue} over a {@link ConcurrentLinkedQueue}: multi-producer, multi-consumer, @@ -13,157 +12,28 @@ * queue's per-element node, so it does not deliver the allocation win; prefer {@link * MpscWorkQueue}. * - *

A reservation here claims capacity and nothing else. There is no slot to hold, so the element - * joins at the tail when it is filled and the queue keeps fill order rather than claim order — and, - * because no place is ever open in the queue itself, no consumer can find one it has to wait on. - * {@link MpscWorkQueue} pays for claim order with a consumer that cannot see past an open - * reservation; this backing does not have that hazard because it does not offer that guarantee. - * - *

The permit counter is not merely bookkeeping. It is what makes the bound enforceable and - * {@link #size()} constant-time, replacing the hand-rolled cap plus O(n) {@code - * ConcurrentLinkedQueue.size()} walk that call sites otherwise pay on every admission. It costs one - * atomic add per admission and one per consumption; a call site migrating off an uncapped {@code - * ConcurrentLinkedQueue} gets a bound for less than its old size check cost. + *

Storage only: the bound lives in {@link BaseWorkQueue}, which is what replaces the hand-rolled + * cap plus O(n) {@code ConcurrentLinkedQueue.size()} walk such a call site otherwise pays on every + * admission, and makes {@link #size()} constant-time. */ final class LinkedWorkQueue extends BaseWorkQueue { private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); - /** - * Places still available, not places used. Admission spends one and consumption returns it, so - * the bound is 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 AtomicInteger available; - - private final int capacity; - /** * @param capacity the bound, or {@link Integer#MAX_VALUE} to leave the queue unbounded */ LinkedWorkQueue(int capacity) { - this.capacity = capacity; - this.available = new AtomicInteger(capacity); - } - - @Override - boolean admit(Object element) { - if (!claimPlace()) { - return false; - } - queue.offer(element); - return true; - } - - @Override - boolean admit(C context, ContextualProducer producer) { - if (!claimPlace()) { - return false; - } - T element; - try { - element = producer.produce(context); - } catch (Throwable t) { - available.incrementAndGet(); - throw t; - } - queue.offer(element); - return true; - } - - @Override - boolean admit( - C1 first, C2 second, BiContextualProducer producer) { - if (!claimPlace()) { - return false; - } - T element; - try { - element = producer.produce(first, second); - } catch (Throwable t) { - available.incrementAndGet(); - throw t; - } - queue.offer(element); - return true; - } - - /** - * 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. - * - *

The cap itself is exact: the queue never holds more than {@code capacity} elements. 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 (available.decrementAndGet() >= 0) { - return true; - } - available.incrementAndGet(); - return false; - } - - /** - * Capacity claimed ahead of the element that will use it. Filling can only ever offer, because - * the room was already taken; abandoning gives the room back. - */ - private final class LinkedReservation implements Reservation { - private boolean done; - - @Override - public void fill(T element) { - if (element == null) { - throw new NullPointerException("a queue cannot hold null"); - } - if (!done) { - done = true; - queue.offer(element); - } - } - - @Override - public void close() { - if (!done) { - done = true; - available.incrementAndGet(); - } - } - } - - @Override - Reservation reserve() { - return claimPlace() ? new LinkedReservation() : null; - } - - @Override - Object take() { - return poll(); - } - - private Object poll() { - Object element = queue.poll(); - if (element != null) { - available.incrementAndGet(); - } - return element; + super(capacity); } @Override - void discardAll() { - while (take() != null) { - // drain - } + boolean store(Object element) { + return queue.offer(element); } @Override - public int size() { - // Claimants at the boundary can transiently drive the count below zero before backing out. - return Math.max(0, capacity - available.get()); + 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 index 1cbb5be6acb..95b8d087f27 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MpscWorkQueue.java @@ -3,140 +3,41 @@ import org.jctools.queues.MessagePassingQueue; /** - * A {@link WorkQueue} over a JCTools MPSC array queue: many producers, one consumer, bounded by - * construction with no per-element node. + * A {@link WorkQueue} over a JCTools MPSC array queue: many producers, one consumer, no per-element + * node. The preferred backing. * - *

Reserve-before-construct is the backing queue's own {@code fill(Supplier, 1)}, which - * CAS-claims the slot and only then calls the supplier, returning zero without ever calling it when - * there is no room. That makes admission exact rather than best-effort: a rejected element is not - * merely discarded cheaply, it is never built. + *

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 { - /** - * Handed to {@code fill} so the producer runs inside the claimed slot. One small short-lived - * object per producing admission, which never escapes the {@code fill} call and so is a candidate - * for scalar replacement; the payload it defers building is the allocation that matters. - */ - private static final class ProducingSupplier - implements MessagePassingQueue.Supplier { - private final C context; - private final ContextualProducer producer; - - ProducingSupplier(C context, ContextualProducer producer) { - this.context = context; - this.producer = producer; - } - - @Override - public Object get() { - return producer.produce(context); - } - } - - /** The two-context form of {@link ProducingSupplier}, with the same escape-free lifetime. */ - private static final class BiProducingSupplier - implements MessagePassingQueue.Supplier { - private final C1 first; - private final C2 second; - private final BiContextualProducer producer; - - BiProducingSupplier( - C1 first, C2 second, BiContextualProducer producer) { - this.first = first; - this.second = second; - this.producer = producer; - } - - @Override - public Object get() { - return producer.produce(first, second); - } - } - - /** Creates the slot inside the claimed place, and hands it back to the reserving thread. */ - private static final class SlotSupplier implements MessagePassingQueue.Supplier { - Slot slot; - - @Override - public Object get() { - slot = new Slot<>(); - return slot; - } - } - private final MessagePassingQueue queue; - /** - * Set before the first {@link Slot} can reach the array, and never cleared. A queue whose caller - * never reserves keeps the plain consumption path; one that has reserved even once pays a peek - * and a type test per item forever, which is the price of not taxing every other call site. - */ - private volatile boolean reservations; - MpscWorkQueue(int requestedCapacity) { - this.queue = Queues.mpscArrayQueue(requestedCapacity); - } - - @Override - boolean admit(Object element) { - return queue.offer(element); - } - - @Override - boolean admit(C context, ContextualProducer producer) { - return queue.fill(new ProducingSupplier<>(context, producer), 1) == 1; + this(Queues.mpscArrayQueue(requestedCapacity)); } - @Override - boolean admit( - C1 first, C2 second, BiContextualProducer producer) { - return queue.fill(new BiProducingSupplier<>(first, second, producer), 1) == 1; + /** 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 - Reservation reserve() { - // Set first: a slot must never reach the array before the consumer knows to expect one. - reservations = true; - SlotSupplier supplier = new SlotSupplier<>(); - return queue.fill(supplier, 1) == 1 ? supplier.slot : null; - } - - @Override - Object take() { - if (!reservations) { - return queue.poll(); - } - for (; ; ) { - Object head = queue.relaxedPeek(); - if (!(head instanceof Slot)) { - // Either empty, or an ordinary element whose place was never reserved. - return head == null ? null : queue.poll(); - } - Object element = ((Slot) head).element(); - if (element == null) { - // Still being built. The place is claimed, so there is nothing behind it to take either. - return null; - } - queue.poll(); - if (element != Slot.RELEASED) { - return element; - } - // Abandoned without ever being filled: skip it and look at what is behind it. - } - } - - @Override - void discardAll() { - queue.clear(); + boolean store(Object element) { + return queue.offer(element); } @Override - public int size() { - return queue.size(); - } - - int capacity() { - return queue.capacity(); + Object retrieve() { + return queue.poll(); } } 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 index 791061bf78b..2c29e9f992e 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/Reservation.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/Reservation.java @@ -1,14 +1,14 @@ package datadog.common.queue; /** - * A claimed place in a {@link WorkQueue}, for the rare caller that must do work between claiming - * and filling and so cannot express its admission as a {@link Producer}. + * A claimed place in a {@link WorkQueue}, for a caller whose work between claiming and filling + * cannot be expressed as a {@link Producer}. * - *

Capacity is claimed when the reservation is taken and held until it is filled or released, so - * one that is never closed leaks capacity, and on an array-backed queue — where the claim is a slot - * the consumer cannot see past — stalls the consumer as well. Take one only in try-with-resources, - * hold it for as long as it takes to build one element, and prefer the producer forms of {@code - * tryPut}, which cannot be leaked. + *

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. */ public interface Reservation extends AutoCloseable { @@ -19,10 +19,8 @@ public interface Reservation extends AutoCloseable { void fill(T element); /** - * Releases the place if it was never filled. Filling first makes this a no-op. - * - *

Nothing is ever consumed for a released place. Where the claim was a slot, the capacity - * comes back as the consumer passes over it rather than the instant it is released. + * 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 index 380be7b92d6..57eb95db4fa 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java @@ -10,9 +10,10 @@ public interface RetryQueue { /** * Resubmits the failed item. * - *

Reuses the lease the failed item already holds and so cannot fail on capacity. This is the - * overload every ordinary strategy wants: it resubmits without allocating the array the varargs - * form needs. + *

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 rejected retry counts + * as a drop. This is the overload every ordinary strategy wants: it resubmits without allocating + * the array the varargs form needs. * * @return whether the item was resubmitted */ @@ -21,11 +22,10 @@ public interface RetryQueue { /** * Resubmits several items in place of the failed item. * - *

Partitioning failed work into smaller pieces needs slots beyond the one the failed item - * holds, and is a no-op returning {@code false} if they cannot be reserved; the original item - * stays leased and is retried later. + *

Each piece claims its own place, so a partition can be admitted only in part; the return + * value reports whether all of them made it, and each rejection counts as a drop. * - * @return whether the items were resubmitted + * @return whether every item was resubmitted */ @SuppressWarnings("unchecked") boolean retry(T... items); diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/Slot.java b/utils/queue-utils/src/main/java/datadog/common/queue/Slot.java deleted file mode 100644 index a35a1fdd388..00000000000 --- a/utils/queue-utils/src/main/java/datadog/common/queue/Slot.java +++ /dev/null @@ -1,37 +0,0 @@ -package datadog.common.queue; - -/** - * The placeholder a {@link Reservation} leaves in the backing store, so the claimed place keeps its - * position in the queue while the caller builds the element that goes in it. - * - *

The consumer distinguishes a slot from an ordinary element by type, which is why every backing - * stores {@code Object} rather than {@code T}. - */ -final class Slot implements Reservation { - - /** Distinguishes "released without ever being filled" from "still open". */ - static final Object RELEASED = new Object(); - - /** Written by the reserving thread, read by the consumer; null while the place is still open. */ - private volatile Object element; - - Object element() { - return element; - } - - @Override - public void fill(T element) { - if (element == null) { - throw new NullPointerException("a queue cannot hold null"); - } - this.element = element; - } - - @Override - public void close() { - // Only the reserving thread calls fill and close, so a plain check orders them correctly. - if (element == null) { - element = RELEASED; - } - } -} 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 index 591264acb91..8a708753e23 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -68,15 +68,10 @@ boolean tryPut( * Claims a place without supplying its element, for a caller whose work between claiming and * filling cannot be expressed as a {@link Producer}. * - *

This is the escape hatch, and it is a sharper tool than the {@code tryPut} family: the - * consumer cannot see past an open reservation, so one that is not promptly filled or closed - * stalls it. Use try-with-resources. - * - *

What is reserved is capacity — {@link Reservation#fill} cannot be rejected. Whether the - * element also keeps the position it was claimed at depends on the backing: an array-backed queue - * claims a slot, and so holds the order, at the cost of a consumer that cannot see past it until - * it is filled; a linked queue has no slot to hold and joins the element at the tail when it is - * filled, so nothing stalls and the order is the fill order. + *

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. * * @return the claimed capacity, or {@code null} if there was none to claim */ 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 index f4c739d7522..20fd44f7053 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -318,33 +318,43 @@ void reserveFailsOnceClosed(String name, IntFunction> factory) } /** The array backing claims a slot, so the element keeps the position it was reserved at. */ - @org.junit.jupiter.api.Test - void arrayBackedReservationHoldsItsPosition() { - WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); + @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"); - assertTrue(queue.process(consumed::add), "what was admitted before the claim is unaffected"); - assertFalse( - queue.process(consumed::add), - "holding a position means the consumer cannot see past it, even for what is behind"); - place.fill("second"); + 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("first", "second", "behind"), consumed); + assertEquals(Arrays.asList("filled late"), consumed, "the order is the fill order"); } - /** The linked backing has no slot to hold, so nothing is held in front of the consumer. */ - @org.junit.jupiter.api.Test - void linkedReservationDoesNotStallTheConsumer() { - WorkQueue queue = WorkQueues.createUnboundedMpmcQueue(); + /** + * 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()) { - assertTrue(queue.tryPut("behind")); - assertTrue(queue.process(item -> {}), "an open reservation holds nothing back"); - place.fill("filled late"); + assertEquals(1, queue.process(10, item -> {}), "consumption is not blocked by the claim"); + place.fill("filled"); } - assertEquals(Arrays.asList("filled late"), consumeAll(queue), "the order is the fill order"); + + assertEquals(Arrays.asList("filled"), consumeAll(queue)); } @org.junit.jupiter.api.Test @@ -443,26 +453,27 @@ void processAbandonsTheRestOfTheBatchWhenTheConsumerThrows( assertEquals(0, queue.dropped(), "a failure the caller sees is not a drop"); } - @org.junit.jupiter.api.Test - void processStopsAtAnOpenReservation() { - WorkQueue queue = WorkQueues.createMpscQueue(CAPACITY); + @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( - 1, - queue.process(10, consumed::add), - "an array-backed reservation holds its position, so the batch ends there"); - assertEquals(Arrays.asList("first"), consumed); + 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(2, queue.process(10, consumed::add)); - assertEquals(Arrays.asList("first", "reserved", "behind"), consumed); + assertEquals(1, queue.process(10, consumed::add)); + assertEquals(Arrays.asList("first", "behind", "reserved"), consumed); + assertEquals(0, queue.size()); } @ParameterizedTest(name = "{0}") From a6647540eb7fad84a889ee379e2dd6d5a1548a33 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 00:06:43 -0400 Subject: [PATCH 15/48] Answer a refused claim with a reservation instead of null tryReserve returned null, one line under a javadoc recommending try-with-resources. That pairing compiles into an NPE at fill, on a full queue, in production -- and this module targets Java 8, so the tidy try (place) form is not available to soften it. A refusal is now a stateless singleton reservation: granted() is false, close() has nothing to give back, and fill() discards. Filling it is a no-op rather than a throw, because an exception raised only under backpressure is the same bug wearing a different name. The drop is still counted, at the moment of refusal. Callers who ask granted() first keep the reserve-first guarantee and build nothing for a queue with no room. Callers who do not are back to allocate-then-drop, which is where they were before this queue existed. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 38 ++++++++++++++++--- .../datadog/common/queue/Reservation.java | 34 ++++++++++++++++- .../java/datadog/common/queue/WorkQueue.java | 7 +++- .../common/queue/MpscWorkQueueStressTest.java | 2 +- .../common/queue/WorkQueueContractTest.java | 10 +++-- 5 files changed, 78 insertions(+), 13 deletions(-) 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 index 03bba1a5af8..80c650a3dad 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -44,6 +44,30 @@ private static final class Retried { /** Non-capturing adapters, so the producer forms share one admission path without allocating. */ private static final ContextualProducer, Object> PRODUCE = Producer::produce; + /** + * The answer to every refused claim: a reservation that holds nothing, discards whatever is + * filled into it, and has nothing to give back. It holds no state, so one instance serves every + * queue and every element type. + * + *

Filling it is a no-op rather than a throw. The queue is full exactly when a caller can least + * afford a surprise, and an exception raised only under backpressure is a bug that waits for + * production to appear. The drop is already counted, by {@link #tryReserve} at the moment of + * refusal. + */ + private static final Reservation REFUSED = + new Reservation() { + @Override + public boolean granted() { + return false; + } + + @Override + public void fill(Object element) {} + + @Override + public void close() {} + }; + private final LongAdder dropped = new LongAdder(); private volatile boolean closed; @@ -155,6 +179,11 @@ private boolean storeOrRelease(T element) { private final class PlaceReservation implements Reservation { private boolean done; + @Override + public boolean granted() { + return true; + } + @Override public void fill(T element) { if (element == null) { @@ -248,14 +277,11 @@ public Collection tryPut(Collection elements) { } @Override + @SuppressWarnings("unchecked") public Reservation tryReserve() { - if (closed) { - dropped.increment(); - return null; - } - if (!claimPlace()) { + if (closed || !claimPlace()) { dropped.increment(); - return null; + return (Reservation) REFUSED; } return new PlaceReservation(); } 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 index 2c29e9f992e..e901ab86bf2 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/Reservation.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/Reservation.java @@ -9,12 +9,42 @@ * 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 { /** - * Publishes {@code element} into the claimed place. The place is already claimed, so this cannot - * fail and cannot be rejected. + * 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); 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 index 8a708753e23..a6f06815f1c 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -73,7 +73,12 @@ boolean tryPut( * 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. * - * @return the claimed capacity, or {@code null} if there was none to claim + *

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(); 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 index f00e7375970..18b29593d9f 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/MpscWorkQueueStressTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/MpscWorkQueueStressTest.java @@ -188,7 +188,7 @@ void conservesElementsWhenProducersReserve() throws Exception { break; case 1: try (Reservation place = queue.tryReserve()) { - if (place != null) { + if (place.granted()) { place.fill(value); admitted.incrementAndGet(); } 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 index 20fd44f7053..48893b30893 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -3,7 +3,6 @@ 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.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -305,8 +304,13 @@ void reserveFailsWhenThereIsNoRoom(String name, IntFunction> f for (int i = 0; i < CAPACITY; i++) { assertTrue(queue.tryPut("e" + i)); } - assertNull(queue.tryReserve()); + Reservation refused = queue.tryReserve(); + assertFalse(refused.granted(), "a refusal is a reservation, never null"); assertEquals(1, queue.dropped(), "a place that could not be claimed counts like a rejection"); + + refused.fill("discarded"); + refused.close(); + assertEquals(CAPACITY, queue.size(), "filling a refusal changes nothing and does not throw"); } @ParameterizedTest(name = "{0}") @@ -314,7 +318,7 @@ void reserveFailsWhenThereIsNoRoom(String name, IntFunction> f void reserveFailsOnceClosed(String name, IntFunction> factory) { WorkQueue queue = factory.apply(CAPACITY); queue.close(); - assertNull(queue.tryReserve()); + assertFalse(queue.tryReserve().granted()); } /** The array backing claims a slot, so the element keeps the position it was reserved at. */ From a9f6c43bef270beecdc22d32d96792cc76350eb9 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 00:21:46 -0400 Subject: [PATCH 16/48] Say which admission form to reach for, and stop describing a stall Two corrections to the class javadoc. It still said a producer runs while holding capacity a consumer may be waiting on, which stopped being true when reservations became capacity rather than position -- a slow producer now taxes other producers, not the consumer. And the admission forms were listed as peers. They are not: the producer forms are forEach and tryReserve is Iterator. With a producer the queue owns the loop and there is no protocol to get wrong; a reservation hands the loop back, with a granted() to check, a fill-or-close obligation, and an abandoned one costing capacity nobody can see -- just as a half-consumed iterator is state its collection cannot account for. Co-Authored-By: Claude Opus 5 --- .../java/datadog/common/queue/WorkQueue.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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 index a6f06815f1c..c5e1e9d10f3 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -9,13 +9,23 @@ * element it is going to reject. * *

Capacity is fixed by construction. A queue never grows in response to fullness: full means - * drop and count. Admission reserves a slot before invoking any producer, so a rejected element is + * drop and count. 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. * - *

Because the slot is claimed first, a producer runs while holding capacity a consumer may be - * waiting on. Producers should build their element and nothing else: work that blocks, or that - * takes appreciably longer than an allocation, stalls the consumer rather than merely the producer. + *

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 From d47180b5e17c0e8b91b00fc299612f072920851e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 00:38:59 -0400 Subject: [PATCH 17/48] Mark the producer forms as strategies The no-allocation claim rests entirely on producers being non-capturing constants, so @Strategy and @StrategyConsumer say it in the place a checker can eventually enforce rather than in prose a caller can skim. Producer, ContextualProducer, BiContextualProducer and RetryStrategy are strategy types; the tryPut slots that take them are strategy slots, and the admit paths that must inline for them to specialize are marked as their consumers. Producer's javadoc now states why capture is disqualifying rather than merely wasteful: a capturing lambda allocates per call and so does a Reservation, but the reservation is straight-line, keeps whatever the call site hoisted, and needs no context parameters. A producer that captures is strictly worse than the form it was meant to improve on, so state that will not fit the context parameters belongs in tryReserve. The plain Consumer slots on process are deliberately unmarked: a consumer that accumulates is normal and correct -- the client-stats Drainer holds its own stopped flag -- so asserting the discipline there would be a promise callers cannot keep. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 11 +++++++++-- .../common/queue/BiContextualProducer.java | 3 +++ .../common/queue/ContextualProducer.java | 3 +++ .../java/datadog/common/queue/Producer.java | 16 ++++++++++++---- .../datadog/common/queue/RetryStrategy.java | 3 +++ .../java/datadog/common/queue/WorkQueue.java | 19 ++++++++++++++----- 6 files changed, 44 insertions(+), 11 deletions(-) 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 index 80c650a3dad..08679fcf8fe 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -2,6 +2,8 @@ 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.List; @@ -134,7 +136,9 @@ private boolean admit(Object element) { return false; } - private boolean admit(C context, ContextualProducer producer) { + @StrategyConsumer + private boolean admit( + C context, @Strategy ContextualProducer producer) { if (!claimPlace()) { return false; } @@ -148,8 +152,11 @@ private boolean admit(C context, ContextualProducer return storeOrRelease(element); } + @StrategyConsumer private boolean admit( - C1 first, C2 second, BiContextualProducer producer) { + C1 first, + C2 second, + @Strategy BiContextualProducer producer) { if (!claimPlace()) { return false; } 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 index 9f7980ae906..cfc8fc72cbb 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BiContextualProducer.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BiContextualProducer.java @@ -1,5 +1,7 @@ package datadog.common.queue; +import datadog.trace.api.function.Strategy; + /** * A {@link Producer} that derives its element from two caller-supplied contexts. * @@ -13,6 +15,7 @@ * 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 index 12c9383303f..9e556b84697 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/ContextualProducer.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/ContextualProducer.java @@ -1,11 +1,14 @@ 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/Producer.java b/utils/queue-utils/src/main/java/datadog/common/queue/Producer.java index acb884355d2..8b384c7daea 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/Producer.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/Producer.java @@ -1,13 +1,21 @@ 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 slot has been reserved, so it is never called for an element - * that will be rejected. Implementations are expected to be non-capturing {@code static final} - * singletons; a capturing lambda allocates per call and defeats the purpose of deferring - * construction. + *

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/RetryStrategy.java b/utils/queue-utils/src/main/java/datadog/common/queue/RetryStrategy.java index f4c478865c7..e8da41df964 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/RetryStrategy.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/RetryStrategy.java @@ -1,5 +1,7 @@ package datadog.common.queue; +import datadog.trace.api.function.Strategy; + /** * Decides what happens to an item whose consumer threw. * @@ -7,6 +9,7 @@ * 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 { /** 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 index c5e1e9d10f3..6ce9dba71d3 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -1,5 +1,7 @@ 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; @@ -45,14 +47,16 @@ public interface WorkQueue { * * @return whether the element was admitted */ - boolean tryPut(Producer producer); + @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 */ - boolean tryPut(C context, ContextualProducer producer); + @StrategyConsumer + boolean tryPut(C context, @Strategy ContextualProducer producer); /** * Admits an element derived from two contexts, constructing it only once a slot is reserved. @@ -60,8 +64,11 @@ public interface WorkQueue { * @return whether the element was admitted * @see BiContextualProducer */ + @StrategyConsumer boolean tryPut( - C1 first, C2 second, BiContextualProducer producer); + C1 first, + C2 second, + @Strategy BiContextualProducer producer); /** * @return the elements that were not admitted, empty if all were @@ -105,7 +112,7 @@ boolean tryPut( * * @return whether there was an item to consume */ - boolean process(Consumer consumer, RetryStrategy retryStrategy); + boolean process(Consumer consumer, @Strategy RetryStrategy retryStrategy); /** * Consumes one item, if there is one. A throwing consumer propagates. @@ -121,7 +128,9 @@ boolean tryPut( * @return whether there was an item to consume */ boolean process( - C context, BiConsumer consumer, RetryStrategy retryStrategy); + C context, + BiConsumer consumer, + @Strategy RetryStrategy retryStrategy); /** * Consumes up to {@code limit} items, stopping early when the queue runs dry. From 27416897b31cddb1cee7f1aafa917bf3d96ff7e7 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 12:37:29 -0400 Subject: [PATCH 18/48] Answer the review on the admission and consumption edges - Retried becomes Retry, present tense like the rest of the names. - BaseWorkQueue's implementations are final: two backings, one body each. - tryPut(Collection) becomes tryPutBatch(Collection), matching the varargs form. - Both batch forms size the reject list from what is left rather than regrowing. - process(Consumer, ExceptionHandler) handles a failure without deciding to retry. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 84 ++++++++++++------- .../common/queue/ExceptionHandler.java | 23 +++++ .../java/datadog/common/queue/WorkQueue.java | 13 ++- .../common/queue/WorkQueueContractTest.java | 49 +++++++++++ 4 files changed, 136 insertions(+), 33 deletions(-) create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/ExceptionHandler.java 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 index 08679fcf8fe..964391ff98a 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -33,11 +33,11 @@ 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 Retried { + private static final class Retry { final T item; final int attempt; - Retried(T item, int attempt) { + Retry(T item, int attempt) { this.item = item; this.attempt = attempt; } @@ -227,29 +227,29 @@ private void discardAll() { } @Override - public int size() { + public final int size() { // Claimants at the boundary can transiently drive the count below zero before backing out. return Math.max(0, capacity - available.get()); } @Override - public boolean tryPut(T element) { + public final boolean tryPut(T element) { return record(!closed && admit(element)); } @Override @SuppressWarnings({"unchecked", "rawtypes"}) - public boolean tryPut(Producer producer) { + public final boolean tryPut(Producer producer) { return record(!closed && admit(producer, (ContextualProducer) PRODUCE)); } @Override - public boolean tryPut(C context, ContextualProducer producer) { + public final boolean tryPut(C context, ContextualProducer producer) { return record(!closed && admit(context, producer)); } @Override - public boolean tryPut( + public final boolean tryPut( C1 first, C2 second, BiContextualProducer producer) { return record(!closed && admit(first, second, producer)); } @@ -258,10 +258,14 @@ public boolean tryPut( @SafeVarargs public final Collection tryPutBatch(T... elements) { List rejected = null; - for (T element : elements) { + for (int i = 0; i < elements.length; i++) { + T element = elements[i]; if (!tryPut(element)) { if (rejected == null) { - rejected = new ArrayList<>(); + // 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. + rejected = new ArrayList<>(elements.length - i); } rejected.add(element); } @@ -270,22 +274,24 @@ public final Collection tryPutBatch(T... elements) { } @Override - public Collection tryPut(Collection elements) { + public final Collection tryPutBatch(Collection elements) { List rejected = null; + int remaining = elements.size(); for (T element : elements) { if (!tryPut(element)) { if (rejected == null) { - rejected = new ArrayList<>(); + rejected = new ArrayList<>(remaining); } rejected.add(element); } + remaining--; } return rejected == null ? emptyList() : rejected; } @Override @SuppressWarnings("unchecked") - public Reservation tryReserve() { + public final Reservation tryReserve() { if (closed || !claimPlace()) { dropped.increment(); return (Reservation) REFUSED; @@ -294,43 +300,53 @@ public Reservation tryReserve() { } @Override - public boolean process(Consumer consumer) { + public final boolean process(Consumer consumer) { return process(consumer, (RetryStrategy) null); } @Override - public boolean process(Consumer consumer, RetryStrategy retryStrategy) { + public final boolean process(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 process(Consumer consumer, RetryStrategy retryStrategy) { Object raw = take(); if (raw == null) { return false; } - consume(raw, consumer, null, null, retryStrategy); + consume(raw, consumer, null, null, retryStrategy, null); return true; } @Override - public boolean process(C context, BiConsumer consumer) { + public final boolean process(C context, BiConsumer consumer) { return process(context, consumer, (RetryStrategy) null); } @Override - public boolean process( + public final boolean process( C context, BiConsumer consumer, RetryStrategy retryStrategy) { Object raw = take(); if (raw == null) { return false; } - consume(raw, null, context, consumer, retryStrategy); + consume(raw, null, context, consumer, retryStrategy, null); return true; } @Override - public int process(int limit, Consumer consumer) { + public final int process(int limit, Consumer consumer) { return process(limit, consumer, null, null); } @Override - public int process(int limit, C context, BiConsumer consumer) { + public final int process(int limit, C context, BiConsumer consumer) { return process(limit, null, context, consumer); } @@ -348,7 +364,7 @@ private int process( // 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. consumed++; - consume(raw, consumer, context, biConsumer, null); + consume(raw, consumer, context, biConsumer, null, null); } return consumed; } @@ -359,18 +375,19 @@ private void consume( Consumer consumer, C context, BiConsumer biConsumer, - RetryStrategy retryStrategy) { + RetryStrategy retryStrategy, + ExceptionHandler exceptionHandler) { T item; int attempt; - if (raw instanceof Retried) { - Retried retried = (Retried) raw; + if (raw instanceof Retry) { + Retry retried = (Retry) raw; item = retried.item; attempt = retried.attempt; } else { item = (T) raw; attempt = 0; } - if (retryStrategy == null) { + 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. @@ -388,7 +405,10 @@ private void consume( biConsumer.accept(context, item); } } catch (Throwable failure) { - if (!retryStrategy.onFailure(item, attempt + 1, failure, lease(attempt + 1))) { + if (exceptionHandler != null) { + dropped.increment(); + exceptionHandler.handle(failure); + } else if (!retryStrategy.onFailure(item, attempt + 1, failure, lease(attempt + 1))) { dropped.increment(); } } @@ -399,7 +419,7 @@ private RetryQueue lease(int attempt) { return new RetryQueue() { @Override public boolean retry(T item) { - if (closed || !admit(new Retried<>(item, attempt))) { + if (closed || !admit(new Retry<>(item, attempt))) { dropped.increment(); return false; } @@ -426,27 +446,27 @@ private boolean record(boolean admitted) { } @Override - public long dropped() { + public final long dropped() { return dropped.sum(); } @Override - public void close() { + public final void close() { closed = true; } @Override - public boolean isClosed() { + public final boolean isClosed() { return closed; } @Override - public void clear() { + public final void clear() { discardAll(); } @Override - public void shutdown() { + public final void shutdown() { closed = true; discardAll(); } 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..38dc493eb2d --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/ExceptionHandler.java @@ -0,0 +1,23 @@ +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 process}. + * + *

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". + * + * @see WorkQueue#process(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(Throwable failure); +} 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 index 6ce9dba71d3..8ebaddec240 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -79,7 +79,7 @@ boolean tryPut( /** * @return the elements that were not admitted, empty if all were */ - Collection tryPut(Collection elements); + Collection tryPutBatch(Collection elements); /** * Claims a place without supplying its element, for a caller whose work between claiming and @@ -114,6 +114,17 @@ boolean tryPut( */ boolean process(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. + * + *

Pass an explicitly typed lambda or a cast: an inexact method reference cannot tell this + * overload from {@link #process(Consumer, RetryStrategy)}. + * + * @return whether there was an item to consume + */ + boolean process(Consumer consumer, @Strategy ExceptionHandler exceptionHandler); + /** * Consumes one item, if there is one. A throwing consumer propagates. * 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 index 48893b30893..21926ecc7bc 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -80,6 +80,55 @@ void batchAdmissionReportsRejectedElements(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)); + assertEquals(2, queue.dropped()); + } + + @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.process( + item -> { + throw new IllegalStateException("boom"); + }, + (ExceptionHandler) seen::add)); + + assertEquals(1, seen.size()); + assertEquals("boom", seen.get(0).getMessage()); + assertEquals(1, queue.dropped()); + 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.process( + consumed::add, + (ExceptionHandler) failure -> fail("handler ran for a consumer that did not throw"))); + + assertEquals(Arrays.asList("a"), consumed); + assertEquals(0, queue.dropped()); + } + @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") void processReportsWhetherThereWasWork(String name, IntFunction> factory) { From e3594a5f74e025ad7aad854affe0ba440b6c8ffe Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 12:47:57 -0400 Subject: [PATCH 19/48] Name the failure-handling forms apart from plain process An ExceptionHandler now takes the item as well as the throwable: the consumer that threw cannot say which one died. process(Consumer, RetryStrategy) becomes processOrRetry, and the handler form processOrHandle, so no two-argument process overloads remain to be told apart by arity. That also settles the older process(consumer, null) ambiguity. The context forms get the same treatment, including a new processOrHandle(C, BiConsumer, ExceptionHandler). Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 29 ++++++++++++++----- .../common/queue/ExceptionHandler.java | 11 +++---- .../java/datadog/common/queue/WorkQueue.java | 28 +++++++++++++----- .../common/queue/WorkQueueContractTest.java | 21 +++++++------- 4 files changed, 58 insertions(+), 31 deletions(-) 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 index 964391ff98a..4dc98ee6c7f 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -301,11 +301,12 @@ public final Reservation tryReserve() { @Override public final boolean process(Consumer consumer) { - return process(consumer, (RetryStrategy) null); + return processOrRetry(consumer, null); } @Override - public final boolean process(Consumer consumer, ExceptionHandler exceptionHandler) { + public final boolean processOrHandle( + Consumer consumer, ExceptionHandler exceptionHandler) { Object raw = take(); if (raw == null) { return false; @@ -315,7 +316,8 @@ public final boolean process(Consumer consumer, ExceptionHandler exce } @Override - public final boolean process(Consumer consumer, RetryStrategy retryStrategy) { + public final boolean processOrRetry( + Consumer consumer, RetryStrategy retryStrategy) { Object raw = take(); if (raw == null) { return false; @@ -326,11 +328,11 @@ public final boolean process(Consumer consumer, RetryStrategy retr @Override public final boolean process(C context, BiConsumer consumer) { - return process(context, consumer, (RetryStrategy) null); + return processOrRetry(context, consumer, null); } @Override - public final boolean process( + public final boolean processOrRetry( C context, BiConsumer consumer, RetryStrategy retryStrategy) { Object raw = take(); if (raw == null) { @@ -340,6 +342,19 @@ public final boolean process( 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); @@ -376,7 +391,7 @@ private void consume( C context, BiConsumer biConsumer, RetryStrategy retryStrategy, - ExceptionHandler exceptionHandler) { + ExceptionHandler exceptionHandler) { T item; int attempt; if (raw instanceof Retry) { @@ -407,7 +422,7 @@ private void consume( } catch (Throwable failure) { if (exceptionHandler != null) { dropped.increment(); - exceptionHandler.handle(failure); + exceptionHandler.handle(item, failure); } else if (!retryStrategy.onFailure(item, attempt + 1, failure, lease(attempt + 1))) { dropped.increment(); } 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 index 38dc493eb2d..7d2d996bd0a 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/ExceptionHandler.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/ExceptionHandler.java @@ -5,19 +5,20 @@ /** * 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 process}. + * 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". + * 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#process(java.util.function.Consumer, ExceptionHandler) + * @see WorkQueue#processOrHandle(java.util.function.Consumer, ExceptionHandler) */ @Strategy @FunctionalInterface -public interface ExceptionHandler { +public interface ExceptionHandler { /** * Called on the consuming thread, in place of propagating. A handler that throws propagates in * the failure's stead. */ - void handle(Throwable failure); + void handle(T item, Throwable failure); } 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 index 8ebaddec240..fcfbbe43ae9 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -32,8 +32,11 @@ *

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} unless a {@link RetryStrategy} was supplied to handle it — the queue takes - * no view on failure it was not given one for, and never logs. + * 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. */ public interface WorkQueue { @@ -112,18 +115,16 @@ boolean tryPut( * * @return whether there was an item to consume */ - boolean process(Consumer consumer, @Strategy RetryStrategy retryStrategy); + 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. * - *

Pass an explicitly typed lambda or a cast: an inexact method reference cannot tell this - * overload from {@link #process(Consumer, RetryStrategy)}. - * * @return whether there was an item to consume */ - boolean process(Consumer consumer, @Strategy ExceptionHandler exceptionHandler); + boolean processOrHandle( + Consumer consumer, @Strategy ExceptionHandler exceptionHandler); /** * Consumes one item, if there is one. A throwing consumer propagates. @@ -138,11 +139,22 @@ boolean tryPut( * * @return whether there was an item to consume */ - boolean process( + 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. * 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 index 21926ecc7bc..f6ad4d8ae51 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -97,16 +97,15 @@ void exceptionHandlerSeesTheFailureAndTheItemIsDropped( WorkQueue queue = factory.apply(CAPACITY); queue.tryPut("a"); - List seen = new ArrayList<>(); + List seen = new ArrayList<>(); assertTrue( - queue.process( + queue.processOrHandle( item -> { throw new IllegalStateException("boom"); }, - (ExceptionHandler) seen::add)); + (item, failure) -> seen.add(item + ":" + failure.getMessage()))); - assertEquals(1, seen.size()); - assertEquals("boom", seen.get(0).getMessage()); + assertEquals(Arrays.asList("a:boom"), seen, "the handler is told which item died"); assertEquals(1, queue.dropped()); assertEquals(0, queue.size()); assertFalse(queue.process(item -> fail("nothing should be left"))); @@ -121,9 +120,9 @@ void exceptionHandlerIsNotCalledWhenTheConsumerSucceeds( List consumed = new ArrayList<>(); assertTrue( - queue.process( + queue.processOrHandle( consumed::add, - (ExceptionHandler) failure -> fail("handler ran for a consumer that did not throw"))); + (item, failure) -> fail("handler ran for a consumer that did not throw"))); assertEquals(Arrays.asList("a"), consumed); assertEquals(0, queue.dropped()); @@ -169,7 +168,7 @@ void processReportsWorkEvenWhenTheStrategyGivesUp( queue.tryPut("a"); RetryStrategy giveUp = (item, attempt, failure, retryQueue) -> false; assertTrue( - queue.process( + queue.processOrRetry( item -> { throw new IllegalStateException("boom"); }, @@ -192,7 +191,7 @@ void retriesUntilTheStrategyGivesUp(String name, IntFunction> return attempt < 2 && retryQueue.retry(item); }; - while (queue.process( + while (queue.processOrRetry( item -> { attempts.incrementAndGet(); throw new IllegalStateException("boom"); @@ -214,7 +213,7 @@ void maxRetriesBoundsResubmission(String name, IntFunction> fa AtomicInteger attempts = new AtomicInteger(); RetryStrategy strategy = new MaxRetries<>(3); - while (queue.process( + while (queue.processOrRetry( item -> { attempts.incrementAndGet(); throw new IllegalStateException("boom"); @@ -291,7 +290,7 @@ void retryCanPartitionFailedWorkIntoSeveralItems( RetryStrategy split = (item, attempt, failure, retryQueue) -> retryQueue.retry("a", "b"); - while (queue.process( + while (queue.processOrRetry( item -> { if (item.length() > 1) { throw new IllegalStateException("too big to handle in one piece"); From 833ca7fb1586dadf041db0f0acbf0217bc60e8c1 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 14:05:29 -0400 Subject: [PATCH 20/48] Benchmark admission against both backings Exercises the four producer forms, the reservation pair, and the refused path, parameterized by backing so the template method's shared store() call site is measured at one receiver type and at two. That call site is the reason the number of backings loaded in a process is an admission cost and not just a dispatch cost. The code shapes underneath this were studied separately and now live on dougqh/apmlp-1799-try-t, since the question generalizes past the queue. Co-Authored-By: Claude Opus 5 --- utils/queue-utils/build.gradle.kts | 1 + .../common/queue/AdmissionBenchmark.java | 153 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionBenchmark.java 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/AdmissionBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionBenchmark.java new file mode 100644 index 00000000000..69587dd8bc1 --- /dev/null +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionBenchmark.java @@ -0,0 +1,153 @@ +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.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 ONE} loads a single + * concrete subclass, so {@code store} is monomorphic and C2 inlines it outright; {@code BOTH} loads + * two, which C2 still inlines behind a type guard. A third backing would be the cliff. Measuring + * both is how we find out whether the inheritance layout costs anything today, or only threatens + * to. + * + *

Results, filled in as they are measured: + * + *

+ * Benchmark                (backings)    ns/op    B/op
+ * tryPutElement            ONE           ?        ?
+ * tryPutElement            BOTH          ?        ?
+ * tryPutContextual         ONE           ?        ?
+ * tryPutContextual         BOTH          ?        ?
+ * tryPutBiContextual       ONE           ?        ?
+ * tryPutBiContextual       BOTH          ?        ?
+ * reserveAndFill           ONE           ?        ?
+ * reserveAndFill           BOTH          ?        ?
+ * reserveRefused           ONE           ?        ?
+ * reserveRefused           BOTH          ?        ?
+ * 
+ */ +@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 + } + + 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"}) + public Backings backings; + + /** The queue under test. */ + private WorkQueue queue; + + /** Kept full for the whole run, so its reservations are always refused. */ + private WorkQueue full; + + /** + * Present only to put a second concrete subclass into the profile. Its call sites are the same + * ones the queue under test uses, which is exactly the pollution being measured. + */ + private WorkQueue other; + + @Setup + public void setUp(Blackhole bh) { + queue = WorkQueues.createMpscQueue(1024); + full = WorkQueues.createMpscQueue(1); + full.tryPut(ELEMENT); + if (backings == Backings.BOTH) { + other = WorkQueues.createMpmcQueue(1024); + // Warm the other backing through the same methods, so both types reach the call sites. + for (int i = 0; i < 20_000; i++) { + other.tryPut(ELEMENT); + other.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(); + } + } +} From adabbda8d9c2913c160b50be4a224d45262a148e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 14:33:56 -0400 Subject: [PATCH 21/48] Build a refusal instead of sharing one tryReserve returned either a fresh PlaceReservation or a static REFUSED. Merging an allocation with a globally reachable reference at a phi is a shape escape analysis gives up on, so at a call site that sees both outcomes the granted reservation is allocated for real. The new reserveMixed arm measures 12 B/op that way and 0 with a single allocation site carrying the outcome in a field, on JDK 17. The condition is worth stating precisely, because the first two arms do not show it: reserveAndFill and reserveRefused each see one outcome, C2 prunes the branch that never runs, and both designs read 0. This is insurance for the caller sitting at the capacity boundary, not a saving for everyone -- but it is free insurance, and it also keeps fill and close monomorphic for callers that never see a refusal and drops the Reservation cast. Also record on store() what the shared call site costs at a third backing, since that is the point at which the template method should give way. Co-Authored-By: Claude Opus 5 --- .../common/queue/AdmissionBenchmark.java | 40 +++++++++-- .../datadog/common/queue/BaseWorkQueue.java | 72 +++++++++++++------ 2 files changed, 85 insertions(+), 27 deletions(-) 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 index 69587dd8bc1..571ae4be1f9 100644 --- a/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionBenchmark.java +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionBenchmark.java @@ -44,11 +44,16 @@ * tryPutContextual BOTH ? ? * tryPutBiContextual ONE ? ? * tryPutBiContextual BOTH ? ? - * reserveAndFill ONE ? ? - * reserveAndFill BOTH ? ? - * reserveRefused ONE ? ? - * reserveRefused BOTH ? ? + * reserveAndFill ONE 20.4 0 + * reserveAndFill BOTH 20.6 0 + * reserveRefused ONE 13.8 0 + * reserveRefused BOTH 13.9 0 + * reserveMixed ONE 13.6 0 (12 with a shared refusal singleton) + * reserveMixed BOTH 13.6 0 (12 with a shared refusal singleton) * + * + *

JDK 17, one machine, {@code -Pjmh.forks=1}. The single-outcome arms cannot distinguish the two + * refusal designs; only {@code reserveMixed} can. */ @Fork(2) @Warmup(iterations = 3, time = 1) @@ -76,6 +81,9 @@ public enum Backings { @Param({"ONE", "BOTH"}) 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; @@ -150,4 +158,28 @@ public void reserveRefused(Blackhole bh) { 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/main/java/datadog/common/queue/BaseWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java index 4dc98ee6c7f..d8554c66dee 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -56,21 +56,8 @@ private static final class Retry { * production to appear. The drop is already counted, by {@link #tryReserve} at the moment of * refusal. */ - private static final Reservation REFUSED = - new Reservation() { - @Override - public boolean granted() { - return false; - } - - @Override - public void fill(Object element) {} - - @Override - public void close() {} - }; - private final LongAdder dropped = new LongAdder(); + private volatile boolean closed; /** @@ -94,6 +81,14 @@ public void close() {} * * @return whether the element was stored */ + /** + * The one call site every backing funnels through, which is why the count of backings loaded in a + * process is an admission cost and not only a dispatch cost. At one or two implementations this + * site is free; a third makes it megamorphic, measured at 24 bytes and roughly three times the + * time per call — paid by callers that only ever touch one backing. A third backing is therefore + * a decision about every existing caller, and the point at which to replace this template method + * with a per-caller strategy so the sites stay separate. + */ abstract boolean store(Object element); /** @@ -183,23 +178,55 @@ private boolean storeOrRelease(T element) { * 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, 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. + */ private final class PlaceReservation implements Reservation { + private final boolean granted; private boolean done; + PlaceReservation(boolean granted) { + this.granted = granted; + this.done = !granted; + } + @Override public boolean granted() { - return true; + 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; + } if (element == null) { throw new NullPointerException("a queue cannot hold null"); } - if (!done) { - done = true; - store(element); - } + done = true; + store(element); } @Override @@ -290,13 +317,12 @@ public final Collection tryPutBatch(Collection elements) { } @Override - @SuppressWarnings("unchecked") public final Reservation tryReserve() { - if (closed || !claimPlace()) { + boolean granted = !closed && claimPlace(); + if (!granted) { dropped.increment(); - return (Reservation) REFUSED; } - return new PlaceReservation(); + return new PlaceReservation(granted); } @Override From ed3889906ba83bf8d524a14ce436d89a235bc626 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 23:00:07 -0400 Subject: [PATCH 22/48] Hand the reservation its queue instead of hiding it The reference was already a field; an inner class only kept it out of sight. The shape here is asking escape analysis to delete the object and promote its fields, so the field count is the subject of the design and a hidden field is a hidden part of it. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) 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 index d8554c66dee..7a3a4915ecd 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -177,18 +177,15 @@ private boolean storeOrRelease(T element) { * 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, 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. + *

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 @@ -200,12 +197,20 @@ private boolean storeOrRelease(T element) { * *

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 final class PlaceReservation implements Reservation { + private static final class PlaceReservation implements Reservation { + private final BaseWorkQueue queue; private final boolean granted; private boolean done; - PlaceReservation(boolean granted) { + PlaceReservation(BaseWorkQueue queue, boolean granted) { + this.queue = queue; this.granted = granted; this.done = !granted; } @@ -226,7 +231,7 @@ public void fill(T element) { throw new NullPointerException("a queue cannot hold null"); } done = true; - store(element); + queue.store(element); } @Override @@ -234,7 +239,7 @@ public void close() { // Only the reserving thread fills or closes, so a plain flag orders the two correctly. if (!done) { done = true; - releasePlace(); + queue.releasePlace(); } } } @@ -322,7 +327,7 @@ public final Reservation tryReserve() { if (!granted) { dropped.increment(); } - return new PlaceReservation(granted); + return new PlaceReservation<>(this, granted); } @Override From d1553167cd06375a32d2b430bc6047c74b6126e2 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 16:49:05 -0400 Subject: [PATCH 23/48] Let the queue walk a batch and transform as it goes The transforming batch form: the queue owns the walk, claims a place before asking the producer for anything, and reports the source elements it could not ask about. Reuses BiContextualProducer verbatim rather than adding an interface -- the signature is already (element, hoisted context) -> element, which is exactly what a per-source-element transform needs, so a caller with a bound-once producer field passes the one it already has. A null return declines the source element. That is the caller's own decision rather than a loss, so it is neither returned as a reject nor counted against dropped(), and the place claimed for it goes straight back -- which is what lets a batch of mostly-declined elements still fill the queue with the few it admits. The one imprecision is documented and pinned by a test: once the queue is full, an element the producer would have declined comes back as a reject, because the claim precedes the question. Collection rather than Iterable. Admission runs while there is room and a live consumer keeps making room, so a source with no end would not terminate; the size is also what pre-sizes the rejected list, as in the other batch forms. --- .../datadog/common/queue/BaseWorkQueue.java | 58 ++++++++ .../java/datadog/common/queue/WorkQueue.java | 39 ++++++ .../common/queue/WorkQueueContractTest.java | 129 ++++++++++++++++++ 3 files changed, 226 insertions(+) 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 index 7a3a4915ecd..91b5428c542 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -165,6 +165,45 @@ private boolean admit( return storeOrRelease(element); } + /** + * The per-source-element half of {@link #tryPutBatch(Collection, Object, BiContextualProducer)}. + * + *

Three outcomes collapse into two, because only one of them is a loss. An admitted element + * and a declined one both leave the caller nothing to do: the first is in the queue, the second + * was never meant to be. Only a refusal — no place to claim, or a backing that would not take + * what was produced — hands a source element back and counts a drop. + * + * @return whether the source element was dealt with, whether by admitting it or by declining it + */ + @StrategyConsumer + private boolean admitEach( + E element, + C context, + @Strategy BiContextualProducer producer) { + if (closed || !claimPlace()) { + dropped.increment(); + return false; + } + T produced; + try { + produced = producer.produce(element, context); + } catch (Throwable t) { + releasePlace(); + throw t; + } + if (produced == null) { + // Declined. The place goes back and the caller hears nothing, because nothing was lost. + releasePlace(); + return true; + } + if (store(produced)) { + return true; + } + releasePlace(); + dropped.increment(); + return false; + } + private boolean storeOrRelease(T element) { if (element != null && store(element)) { return true; @@ -321,6 +360,25 @@ public final Collection tryPutBatch(Collection elements) { return rejected == null ? emptyList() : rejected; } + @Override + public final Collection tryPutBatch( + Collection source, + C context, + BiContextualProducer producer) { + List rejected = null; + int remaining = source.size(); + for (E element : source) { + if (!admitEach(element, context, producer)) { + if (rejected == null) { + rejected = new ArrayList<>(remaining); + } + rejected.add(element); + } + remaining--; + } + return rejected == null ? emptyList() : rejected; + } + @Override public final Reservation tryReserve() { boolean granted = !closed && claimPlace(); 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 index fcfbbe43ae9..9f0768e4920 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -84,6 +84,45 @@ boolean tryPut( */ 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 is neither returned as a + * reject nor counted against {@link #dropped()}; the place claimed for it is simply given back. + * Rejects are the source elements the producer was never asked about, because there was no room + * to ask — plus any it produced that the backing then refused. A full queue can therefore hand + * back an element the producer would have declined: the place is claimed before the producer is + * asked, so the queue does not know, and reports what it does know, which is that it could not + * ask. + * + *

{@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 the source elements that were not admitted, empty if all were + * @see BiContextualProducer + */ + @StrategyConsumer + Collection tryPutBatch( + Collection source, + C context, + @Strategy BiContextualProducer producer); + /** * Claims a place without supplying its element, for a caller whose work between claiming and * filling cannot be expressed as a {@link Producer}. 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 index f6ad4d8ae51..cbc2c09b070 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -90,6 +90,135 @@ void collectionAdmissionReportsRejectedElements( assertEquals(2, queue.dropped()); } + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void transformingBatchAdmissionAppliesTheContextToEverySourceElement( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + Collection rejected = + queue.tryPutBatch(Arrays.asList(1, 2, 3), "x", (source, suffix) -> source + suffix); + assertTrue(rejected.isEmpty()); + assertEquals(Arrays.asList("1x", "2x", "3x"), consumeAll(queue)); + assertEquals(0, queue.dropped()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aDeclinedSourceElementIsNeitherRejectedNorDropped( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + // Every other element declined. Returning null is the caller's own decision, so the queue owes + // it no report: nothing comes back as a reject and nothing is counted against dropped(). + Collection rejected = + queue.tryPutBatch( + Arrays.asList(1, 2, 3, 4, 5, 6), + "x", + (source, suffix) -> source % 2 == 0 ? null : source + suffix); + assertTrue(rejected.isEmpty(), "declined elements must not come back as rejects"); + assertEquals(Arrays.asList("1x", "3x", "5x"), consumeAll(queue)); + assertEquals(0, queue.dropped()); + } + + @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. + Collection rejected = + queue.tryPutBatch( + Arrays.asList(1, 2, 3, 4, 5, 6, 7), + "x", + (source, suffix) -> source % 2 == 0 ? null : source + suffix); + assertTrue(rejected.isEmpty()); + assertEquals(Arrays.asList("1x", "3x", "5x", "7x"), consumeAll(queue)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aSourceElementTheProducerWouldHaveDeclinedIsStillARejectOnceFull( + 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. + Collection rejected = + queue.tryPutBatch( + Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8), + "x", + (source, suffix) -> source % 2 == 0 ? null : source + suffix); + assertEquals(Arrays.asList(8), new ArrayList<>(rejected)); + assertEquals(Arrays.asList("1x", "3x", "5x", "7x"), consumeAll(queue)); + assertEquals(1, queue.dropped()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void transformingBatchAdmissionReturnsTheSourceElementsItCouldNotAskAbout( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + List asked = new ArrayList<>(); + Collection rejected = + queue.tryPutBatch( + Arrays.asList(1, 2, 3, 4, 5, 6), + "x", + (source, suffix) -> { + asked.add(source); + return source + suffix; + }); + assertEquals(Arrays.asList(5, 6), new ArrayList<>(rejected)); + // The rejects are exactly the source elements the producer was never asked about -- the point + // of claiming a place before producing. + assertEquals(Arrays.asList(1, 2, 3, 4), asked); + assertEquals(2, queue.dropped()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void transformingBatchAdmissionRejectsEverythingOnceClosed( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + queue.close(); + AtomicBoolean asked = new AtomicBoolean(); + Collection rejected = + queue.tryPutBatch( + Arrays.asList(1, 2), + "x", + (source, suffix) -> { + asked.set(true); + return source + suffix; + }); + assertEquals(Arrays.asList(1, 2), new ArrayList<>(rejected)); + assertFalse(asked.get(), "a closed queue must not ask the producer for anything"); + assertEquals(2, queue.dropped()); + } + + @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 + // four 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( From e5cc78bc21c631d97c4ec16564119aedc13534ea Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 18:02:33 -0400 Subject: [PATCH 24/48] Return how many a batch admitted rather than which were refused The count is the number a caller can act on. A caller that knows how many it meant to admit gets its exact shortfall by subtraction, with its own declines excluded from both sides -- which the refused elements cannot give, because 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. It also stops allocating a list for a caller that only wanted the size of one. --- .../datadog/common/queue/BaseWorkQueue.java | 29 +++---- .../java/datadog/common/queue/WorkQueue.java | 20 ++--- .../common/queue/WorkQueueContractTest.java | 76 ++++++++++--------- 3 files changed, 63 insertions(+), 62 deletions(-) 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 index 91b5428c542..d48f05617c0 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -168,12 +168,12 @@ private boolean admit( /** * The per-source-element half of {@link #tryPutBatch(Collection, Object, BiContextualProducer)}. * - *

Three outcomes collapse into two, because only one of them is a loss. An admitted element - * and a declined one both leave the caller nothing to do: the first is in the queue, the second - * was never meant to be. Only a refusal — no place to claim, or a backing that would not take - * what was produced — hands a source element back and counts a drop. + *

Three outcomes, two of which report as not admitted for different reasons. A decline is the + * caller's own decision, so it gives its place back and counts nothing. A refusal — no place to + * claim, or a backing that would not take what was produced — gives the place back where there is + * one and counts a drop. * - * @return whether the source element was dealt with, whether by admitting it or by declining it + * @return whether the source element was admitted */ @StrategyConsumer private boolean admitEach( @@ -192,9 +192,9 @@ private boolean admitEach( throw t; } if (produced == null) { - // Declined. The place goes back and the caller hears nothing, because nothing was lost. + // Declined. The place goes back and nothing is counted, because nothing was lost. releasePlace(); - return true; + return false; } if (store(produced)) { return true; @@ -361,22 +361,17 @@ public final Collection tryPutBatch(Collection elements) { } @Override - public final Collection tryPutBatch( + public final int tryPutBatch( Collection source, C context, BiContextualProducer producer) { - List rejected = null; - int remaining = source.size(); + int admitted = 0; for (E element : source) { - if (!admitEach(element, context, producer)) { - if (rejected == null) { - rejected = new ArrayList<>(remaining); - } - rejected.add(element); + if (admitEach(element, context, producer)) { + admitted++; } - remaining--; } - return rejected == null ? emptyList() : rejected; + return admitted; } @Override 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 index 9f0768e4920..87dab1807bd 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -90,13 +90,15 @@ boolean tryPut( * 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 is neither returned as a - * reject nor counted against {@link #dropped()}; the place claimed for it is simply given back. - * Rejects are the source elements the producer was never asked about, because there was no room - * to ask — plus any it produced that the backing then refused. A full queue can therefore hand - * back an element the producer would have declined: the place is claimed before the producer is - * asked, so the queue does not know, and reports what it does know, which is that it could not - * ask. + * decision by the caller rather than a loss, so a declined element is not counted against {@link + * #dropped()} and 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 — and counts against {@link #dropped()} — 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 @@ -114,11 +116,11 @@ boolean tryPut( * 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 the source elements that were not admitted, empty if all were + * @return how many elements were admitted * @see BiContextualProducer */ @StrategyConsumer - Collection tryPutBatch( + int tryPutBatch( Collection source, C context, @Strategy BiContextualProducer producer); 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 index cbc2c09b070..a762681c57e 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -95,26 +95,26 @@ void collectionAdmissionReportsRejectedElements( void transformingBatchAdmissionAppliesTheContextToEverySourceElement( String name, IntFunction> factory) { WorkQueue queue = factory.apply(CAPACITY); - Collection rejected = + int admitted = queue.tryPutBatch(Arrays.asList(1, 2, 3), "x", (source, suffix) -> source + suffix); - assertTrue(rejected.isEmpty()); + assertEquals(3, admitted); assertEquals(Arrays.asList("1x", "2x", "3x"), consumeAll(queue)); assertEquals(0, queue.dropped()); } @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void aDeclinedSourceElementIsNeitherRejectedNorDropped( + void aDeclinedSourceElementIsNeitherAdmittedNorDropped( String name, IntFunction> factory) { WorkQueue queue = factory.apply(CAPACITY); - // Every other element declined. Returning null is the caller's own decision, so the queue owes - // it no report: nothing comes back as a reject and nothing is counted against dropped(). - Collection rejected = + // Every other element declined. Returning null is the caller's own decision, so it counts + // against neither the admitted total nor dropped(): 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); - assertTrue(rejected.isEmpty(), "declined elements must not come back as rejects"); + assertEquals(3, admitted); assertEquals(Arrays.asList("1x", "3x", "5x"), consumeAll(queue)); assertEquals(0, queue.dropped()); } @@ -127,40 +127,24 @@ void decliningLeavesTheClaimedPlaceAvailableToTheRestOfTheBatch( // 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. - Collection rejected = + int admitted = queue.tryPutBatch( Arrays.asList(1, 2, 3, 4, 5, 6, 7), "x", (source, suffix) -> source % 2 == 0 ? null : source + suffix); - assertTrue(rejected.isEmpty()); + assertEquals(CAPACITY, admitted); assertEquals(Arrays.asList("1x", "3x", "5x", "7x"), consumeAll(queue)); + assertEquals(0, queue.dropped()); } @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void aSourceElementTheProducerWouldHaveDeclinedIsStillARejectOnceFull( - 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. - Collection rejected = - queue.tryPutBatch( - Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8), - "x", - (source, suffix) -> source % 2 == 0 ? null : source + suffix); - assertEquals(Arrays.asList(8), new ArrayList<>(rejected)); - assertEquals(Arrays.asList("1x", "3x", "5x", "7x"), consumeAll(queue)); - assertEquals(1, queue.dropped()); - } - - @ParameterizedTest(name = "{0}") - @MethodSource("boundedQueues") - void transformingBatchAdmissionReturnsTheSourceElementsItCouldNotAskAbout( + void theShortfallIsExactWhenTheCallerKnowsWhatItMeantToAdmit( String name, IntFunction> factory) { WorkQueue queue = factory.apply(CAPACITY); List asked = new ArrayList<>(); - Collection rejected = + int intended = 6; + int admitted = queue.tryPutBatch( Arrays.asList(1, 2, 3, 4, 5, 6), "x", @@ -168,21 +152,41 @@ void transformingBatchAdmissionReturnsTheSourceElementsItCouldNotAskAbout( asked.add(source); return source + suffix; }); - assertEquals(Arrays.asList(5, 6), new ArrayList<>(rejected)); - // The rejects are exactly the source elements the producer was never asked about -- the point - // of claiming a place before producing. + 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); assertEquals(2, queue.dropped()); } @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void transformingBatchAdmissionRejectsEverythingOnceClosed( + void aSourceElementTheProducerWouldHaveDeclinedIsStillDroppedOnceFull( + 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 counts what is true from where it stands: it could not ask. + // This is why dropped() is approximate for a declining producer and the shortfall is not. + int admitted = + queue.tryPutBatch( + Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8), + "x", + (source, suffix) -> source % 2 == 0 ? null : source + suffix); + assertEquals(CAPACITY, admitted); + assertEquals(Arrays.asList("1x", "3x", "5x", "7x"), consumeAll(queue)); + assertEquals(1, queue.dropped()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void transformingBatchAdmissionAdmitsNothingOnceClosed( String name, IntFunction> factory) { WorkQueue queue = factory.apply(CAPACITY); queue.close(); AtomicBoolean asked = new AtomicBoolean(); - Collection rejected = + int admitted = queue.tryPutBatch( Arrays.asList(1, 2), "x", @@ -190,7 +194,7 @@ void transformingBatchAdmissionRejectsEverythingOnceClosed( asked.set(true); return source + suffix; }); - assertEquals(Arrays.asList(1, 2), new ArrayList<>(rejected)); + assertEquals(0, admitted); assertFalse(asked.get(), "a closed queue must not ask the producer for anything"); assertEquals(2, queue.dropped()); } @@ -214,7 +218,7 @@ void aThrowingTransformGivesBackItsPlaceAndPropagates( return element + suffix; })); // The place claimed for the failed element went back, so the queue still holds capacity for - // four more admissions beyond the one that succeeded. + // three more admissions beyond the one that succeeded. assertEquals(1, queue.size()); assertTrue(queue.tryPutBatch("a", "b", "c").isEmpty()); } From 70984389cf28d1123bb0dadbb81a1f7c4d3d5c67 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 18:03:54 -0400 Subject: [PATCH 25/48] Let a batch caller say where its refusals go A RejectHandler overload, so wanting the refused source elements and wanting only the count are two shapes of one method rather than a return type that serves one of them badly. A caller that only counts pays a null test; a caller that collects picks its own accumulator instead of copying out of ours. The admission-side counterpart to ExceptionHandler, and documented with the one place the line blurs: a place is claimed before the producer is asked, so a full queue hands the handler source elements the producer would have declined, and a caller resubmitting them has to apply its own rule again. --- .../datadog/common/queue/BaseWorkQueue.java | 27 +++++++++++--- .../datadog/common/queue/RejectHandler.java | 27 ++++++++++++++ .../java/datadog/common/queue/WorkQueue.java | 17 +++++++++ .../common/queue/WorkQueueContractTest.java | 36 +++++++++++++++++++ 4 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/RejectHandler.java 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 index d48f05617c0..b078df603df 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -179,9 +179,10 @@ private boolean admit( private boolean admitEach( E element, C context, - @Strategy BiContextualProducer producer) { + @Strategy BiContextualProducer producer, + @Strategy RejectHandler onRejected) { if (closed || !claimPlace()) { - dropped.increment(); + reject(element, onRejected); return false; } T produced; @@ -200,10 +201,19 @@ private boolean admitEach( return true; } releasePlace(); - dropped.increment(); + 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) { + dropped.increment(); + if (onRejected != null) { + onRejected.onRejected(element); + } + } + private boolean storeOrRelease(T element) { if (element != null && store(element)) { return true; @@ -365,9 +375,18 @@ public final int tryPutBatch( Collection source, C context, BiContextualProducer producer) { + return tryPutBatch(source, context, producer, null); + } + + @Override + public final int tryPutBatch( + Collection source, + C context, + BiContextualProducer producer, + RejectHandler onRejected) { int admitted = 0; for (E element : source) { - if (admitEach(element, context, producer)) { + if (admitEach(element, context, producer, onRejected)) { admitted++; } } 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/WorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java index 87dab1807bd..d627ab8e2d3 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -125,6 +125,23 @@ int tryPutBatch( 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}. 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 index a762681c57e..1883e9f9bb9 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -199,6 +199,42 @@ void transformingBatchAdmissionAdmitsNothingOnceClosed( assertEquals(2, queue.dropped()); } + @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); + assertEquals(2, queue.dropped()); + } + + @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"); + assertEquals(0, queue.dropped()); + } + @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") void aThrowingTransformGivesBackItsPlaceAndPropagates( From 58520cef26183840717e5c58351b69649419ce82 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 20:59:59 -0400 Subject: [PATCH 26/48] Say what a null means once, instead of five times differently The class had four answers to the same question. An element of null claimed a place and then threw out of the backing, leaking capacity permanently, once per call, without counting a drop. The same null inside a batch did it partway through, taking the accumulated rejects with it. A producer returning null was a silent refusal counted against dropped() in the single-element forms, but a decline that counted nothing in the batch form -- so the same lambda meant two different things depending on which method it was handed to. Only the reservation's fill() stated a policy out loud. Elements are non-null: neither backing can hold one, so there is no outcome to report, and requireElement throws before a place is claimed. fill() defers to it rather than restating it. A producer returning null is always a decline, never a drop. That removes a policy rather than adding one: tryPut returning false already cannot distinguish "no room" from "declined" and the caller acts the same either way, so dropped() was the only thing that disagreed. Counting now happens where a refusal happens instead of in a wrapper that only saw a boolean and could not tell the two apart, which is what record() is replaced by. Contexts, an optional RejectHandler and a producer's return stay nullable, and WorkQueue's javadoc now says so in one paragraph. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 51 +++++++-- .../java/datadog/common/queue/WorkQueue.java | 16 +++ .../common/queue/WorkQueueContractTest.java | 104 ++++++++++++++++++ 3 files changed, 159 insertions(+), 12 deletions(-) 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 index b078df603df..794dd40ad3f 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -122,12 +122,14 @@ private void releasePlace() { private boolean admit(Object element) { if (!claimPlace()) { + dropped.increment(); return false; } if (store(element)) { return true; } releasePlace(); + dropped.increment(); return false; } @@ -135,6 +137,7 @@ private boolean admit(Object element) { private boolean admit( C context, @Strategy ContextualProducer producer) { if (!claimPlace()) { + dropped.increment(); return false; } T element; @@ -153,6 +156,7 @@ private boolean admit( C2 second, @Strategy BiContextualProducer producer) { if (!claimPlace()) { + dropped.increment(); return false; } T element; @@ -214,11 +218,22 @@ private void reject(E element, @Strategy RejectHandler onRejected } } + /** + * The tail of every producer admission. A {@code null} is the producer declining, which is the + * caller's own decision: the place goes back and nothing is counted, because nothing was lost. A + * backing that would not take what was produced is a refusal, and is counted. Same three outcomes + * as {@link #admitEach}, which walks a source instead of taking one element. + */ private boolean storeOrRelease(T element) { - if (element != null && store(element)) { + if (element == null) { + releasePlace(); + return false; + } + if (store(element)) { return true; } releasePlace(); + dropped.increment(); return false; } @@ -276,9 +291,7 @@ public void fill(T element) { if (done) { return; } - if (element == null) { - throw new NullPointerException("a queue cannot hold null"); - } + requireElement(element); done = true; queue.store(element); } @@ -315,24 +328,25 @@ public final int size() { @Override public final boolean tryPut(T element) { - return record(!closed && admit(element)); + requireElement(element); + return closed ? refuseClosed() : admit(element); } @Override @SuppressWarnings({"unchecked", "rawtypes"}) public final boolean tryPut(Producer producer) { - return record(!closed && admit(producer, (ContextualProducer) PRODUCE)); + return closed ? refuseClosed() : admit(producer, (ContextualProducer) PRODUCE); } @Override public final boolean tryPut(C context, ContextualProducer producer) { - return record(!closed && admit(context, producer)); + return closed ? refuseClosed() : admit(context, producer); } @Override public final boolean tryPut( C1 first, C2 second, BiContextualProducer producer) { - return record(!closed && admit(first, second, producer)); + return closed ? refuseClosed() : admit(first, second, producer); } @Override @@ -556,11 +570,24 @@ public boolean retry(T... items) { }; } - private boolean record(boolean admitted) { - if (!admitted) { - dropped.increment(); + /** + * A refusal that never reached a producer, so nothing was built and nothing could have been + * declined: the queue was already closed when the attempt began. + */ + private boolean refuseClosed() { + dropped.increment(); + return false; + } + + /** + * The one place the module says what a {@code null} element is. Neither backing can hold one, so + * there is no outcome to report and nothing to count -- 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"); } - return admitted; } @Override 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 index d627ab8e2d3..94b346d24c7 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -37,11 +37,27 @@ * 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, nothing is admitted, and + * nothing is counted against {@link #dropped}, because a decision is not a loss. + * + *

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); 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 index 1883e9f9bb9..3b459aafe1b 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -731,6 +731,110 @@ void doesNotInvokeTwoContextProducerWhenFull( assertEquals(1, queue.dropped()); } + // --- 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()); + assertEquals(0, queue.dropped(), "a caller's bug is not a dropped element"); + 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"); + } + + @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 aProducerDecliningIsNeitherAdmittedNorDropped( + 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()); + assertEquals(0, queue.dropped(), "a decline is a decision, not a loss"); + 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 queue = factory.apply(CAPACITY); + int admitted = + queue.tryPutBatch( + Arrays.asList(1, 2, 3, 4, 5, 6), "x", (source, suffix) -> source + suffix, null); + assertEquals(CAPACITY, admitted); + assertEquals(2, queue.dropped()); + } + private static List consumeAll(WorkQueue queue) { List consumed = new ArrayList<>(); while (queue.process(consumed::add)) { From bc7b7bc976536e8d4085804962b18c8d9998a4ca Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 21:27:19 -0400 Subject: [PATCH 27/48] Count a lost item once, not once per step that lost it A refused retry moved dropped() by three. admit(Object) counted the refused claim, the retry lease counted the refusal again, and consume() counted a third time when the strategy reported it gave up -- three increments, one lost item. Two of those were pre-existing; the third arrived with the null-policy commit, which taught admit(Object) to count without noticing that the retry path goes through it too. MpscWorkQueueStressTest's conservation invariant could not catch any of it, because it never retries. The rule is that a refusal is counted where the outcome is decided, and only there. admit(Object) is shared with the retry path, so it counts nothing and tryPut counts its own refusal. A refused retry is a step in a decision the strategy is still making, so the lease counts nothing either: RetryStrategy already contracts to return false when it gives up, and consume() counts that. A strategy that returns true has said it took responsibility, and is believed. The three tests added here fail against the previous commit with dropped() at 3 where 1 is expected, and at 2 where 0 is expected. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 22 ++++-- .../java/datadog/common/queue/RetryQueue.java | 14 ++-- .../common/queue/WorkQueueContractTest.java | 79 +++++++++++++++++++ 3 files changed, 102 insertions(+), 13 deletions(-) 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 index 794dd40ad3f..11ff142f93c 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -120,16 +120,20 @@ private void releasePlace() { available.incrementAndGet(); } + /** + * Counts nothing, unlike the producer admissions below. This one is 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. Each caller counts its own outcome, once. + */ private boolean admit(Object element) { if (!claimPlace()) { - dropped.increment(); return false; } if (store(element)) { return true; } releasePlace(); - dropped.increment(); return false; } @@ -329,7 +333,11 @@ public final int size() { @Override public final boolean tryPut(T element) { requireElement(element); - return closed ? refuseClosed() : admit(element); + if (closed || !admit(element)) { + dropped.increment(); + return false; + } + return true; } @Override @@ -551,11 +559,9 @@ private RetryQueue lease(int attempt) { return new RetryQueue() { @Override public boolean retry(T item) { - if (closed || !admit(new Retry<>(item, attempt))) { - dropped.increment(); - return false; - } - return true; + // No counting here. A refused retry is one step of a decision the strategy is still + // making; the item is counted lost exactly once, when onFailure reports it gave up. + return !closed && admit(new Retry<>(item, attempt)); } @Override 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 index 57eb95db4fa..feba695044a 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java @@ -11,9 +11,11 @@ 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 rejected retry counts - * as a drop. This is the overload every ordinary strategy wants: it resubmits without allocating - * the array the varargs form needs. + * other admission and can be rejected if the queue filled up behind it. A refusal is not itself + * counted as a drop: the item is counted once, when {@link RetryStrategy#onFailure} returns + * {@code false} to say the strategy gave up. A strategy that cannot resubmit must therefore + * report that, or the item is lost without being counted. This is the overload every ordinary + * strategy wants: it resubmits without allocating the array the varargs form needs. * * @return whether the item was resubmitted */ @@ -22,8 +24,10 @@ public interface RetryQueue { /** * Resubmits several items in place of the failed item. * - *

Each piece claims its own place, so a partition can be admitted only in part; the return - * value reports whether all of them made it, and each rejection counts as a drop. + *

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. * * @return whether every item was resubmitted */ 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 index 3b459aafe1b..95d1f735ef9 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -835,6 +835,85 @@ void aNullRejectHandlerSaysWhatOmittingItSays( assertEquals(2, queue.dropped()); } + // --- One lost item, one drop, however many steps it took to lose it. --- + + /** + * The counting bug this pins: a refused retry used to be counted where it was refused AND again + * where the strategy gave up, so one lost item moved dropped() by more than one. The stress + * test's conservation invariant could not see it, because it never retries. + */ + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aRefusedRetryIsCountedOnceWhenTheStrategyGivesUp( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i)); + } + assertEquals(0, queue.dropped()); + 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"); + assertEquals(1, queue.dropped(), "one item was lost, so dropped() moves by exactly one"); + } + + /** + * {@code onFailure} returning true is the strategy saying it took responsibility. The queue takes + * it at its word, which is the residue of counting the outcome rather than the step. + */ + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aRefusedRetryIsNotCountedWhenTheStrategyReportsItHandledIt( + String name, IntFunction> factory) { + WorkQueue queue = factory.apply(CAPACITY); + for (int i = 0; i < CAPACITY; i++) { + assertTrue(queue.tryPut("e" + i)); + } + assertTrue( + queue.processOrRetry( + item -> { + throw new IllegalStateException("consumer failed on " + item); + }, + (item, attempt, failure, retryQueue) -> { + assertTrue(queue.tryPut("filler")); + assertFalse(retryQueue.retry(item)); + return true; + })); + assertEquals(0, queue.dropped()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("boundedQueues") + void aSuccessfulRetryCountsNothing(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(0, queue.dropped(), "nothing was lost"); + assertEquals(CAPACITY, queue.size(), "the retried item took a place again"); + } + private static List consumeAll(WorkQueue queue) { List consumed = new ArrayList<>(); while (queue.process(consumed::add)) { From 0497ad5ea832496fb30539f49d4dd0c05d36ef2c Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 21:27:22 -0400 Subject: [PATCH 28/48] Stop claiming shutdown is atomic, because it is not The javadoc said shutdown() atomically closes and clears, and explained that sequencing the two separately leaves a window a producer can land work through. The implementation is closed = true; discardAll(), which is exactly what close(); clear() does, window included -- so the method claimed to prevent the race it has. Correcting the claim rather than closing the window. Real atomicity needs the closed flag re-read after every producer returns and before its element is stored -- four sites on the admission path, one of them per batch element -- to buy a guarantee that only matters during shutdown. That is the wrong trade to make silently; if we want it, it should be its own change with its own measurement. Says what ordering the flag first does buy, and puts the remaining half of the job where it belongs: a caller that needs the queue provably empty has to quiesce its own producers, which the queue cannot do for it. Co-Authored-By: Claude Opus 5 --- .../main/java/datadog/common/queue/WorkQueue.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 index 94b346d24c7..3c61087b900 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -278,10 +278,17 @@ boolean processOrHandle( void clear(); /** - * Atomically {@link #close() closes} and {@link #clear() clears}. + * {@link #close() Closes} and then {@link #clear() clears} — the flag before the discard, so a + * producer that has not started yet cannot begin. * - *

Sequencing the two separately leaves a window — a producer already past the closed check, an - * in-flight retry lease — through which work can land in a queue nothing will drain again. + *

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(); } From 9e10f2432212e0f127a17b612f4007ae0793604b Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 21:27:37 -0400 Subject: [PATCH 29/48] Benchmark admission with more than one thread admitting AdmissionBenchmark is @Threads(1) and Scope.Thread, so every thread gets its own queue. Two of the costs this module is built around are invisible in that shape. Allocation is the first. One thread allocates for almost nothing -- a pointer bump in a thread-local buffer -- so a per-operation allocation lands in B/op and barely touches ns/op, which is how an allocation on a hot path gets waved through. Several threads allocating together pay buffer refills, the bandwidth to touch fresh lines, and eventually collection, which turns the allocation into a throughput number. The reservation path was measured at 0 B/op against 12 for a shared refusal singleton, at one thread, where 12 B/op is nearly free; this is where it gets priced. refusedProducer against refusedBuildThenOffer is the whole premise of the API in that form: both admit nothing, and one never builds the element it was going to throw away while the other builds it first. Contention is the second. claimPlace spends a place with one atomic decrement and gives it back with a second when there was none, so a refused admission pays two read-modify-writes on one line, at the boundary where the most threads arrive at once. refusedRaw prices it: jctools already bounds the MPSC backing through its own producer-index CAS, so a caller that never reserves is paying the counter for a bound it had for free. The linked backing has no such baseline -- there the counter is the only thing bounding an unbounded queue. The steady arm runs producers against a draining consumer, the only arm where the counter is incremented and decremented on the same line at once. Numbers are not filled in yet; the table in the class javadoc marks the arms. Co-Authored-By: Claude Opus 5 --- .../queue/ContendedAdmissionBenchmark.java | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedAdmissionBenchmark.java 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..5a413f7fcfb --- /dev/null +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedAdmissionBenchmark.java @@ -0,0 +1,211 @@ +package datadog.common.queue; + +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.Group; +import org.openjdk.jmh.annotations.GroupThreads; +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; + +/** + * 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. + * 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. + * + *

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, filled in as they are measured: + * + *

+ * Benchmark                 (backings)   ns/op    B/op
+ * refusedProducer           MPSC         ?        ?
+ * refusedProducer           LINKED       ?        ?
+ * refusedBuildThenOffer     -            ?        ?
+ * refusedQueue              MPSC         ?        ?
+ * refusedQueue              LINKED       ?        ?
+ * refusedRaw                -            ?        ?
+ * steady:produce            MPSC         ?        ?
+ * steady:produce            LINKED       ?        ?
+ * 
+ */ +@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, + LINKED + } + + /** 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", "LINKED"}) + 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; + + @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 + } + } + + 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)); + } + + @Benchmark + @Group("steady") + @GroupThreads(3) + public void produce(Blackhole bh) { + bh.consume(queue.tryPut(ELEMENT)); + } + + /** + * One consumer, because MPSC allows exactly one. Its own timing is not the point; it is here to + * keep {@link #produce} off the boundary and to put the counter's increment side under load at + * the same time as its decrement side. + */ + @Benchmark + @Group("steady") + @GroupThreads(1) + public void consume(Blackhole bh) { + bh.consume(queue.process(CAPACITY, bh::consume)); + } +} From c631f94c74dd68b5ab333c77c129df2f417d35a2 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 22:03:25 -0400 Subject: [PATCH 30/48] Own the drain thread so the contended arm survives a thread-count override The steady arm used a JMH @Group with @GroupThreads(1) for its consumer and a comment asserting "one consumer, because MPSC allows exactly one". @GroupThreads fixes the count per group, and JMH builds as many groups as the thread count allows -- so -Pjmh.threads=8, the project's documented spot-check flag, against a group of 4 produced two consumers on a single-consumer ring. The two did not fail: they spun in jctools' gap-wait and the iteration never ended, so the run burned 28 minutes and emitted nothing. The consumer is now a thread this class starts in setup, which makes the arm correct at any thread count instead of correct at one. Same finding, stated for callers on WorkQueues.createMpscQueue: Single Consumer is a requirement, not a characteristic, and a second consumer presents as a hang rather than an error. Results filled in from an 8-thread run, and they include a reading that does not flatter the API: the permit counter, not the avoided allocation, is the dominant cost at the capacity boundary -- ~960ns to refuse against ~3.4ns on jctools' own bound. Co-Authored-By: Claude Opus 5 --- .../queue/ContendedAdmissionBenchmark.java | 106 +++++++++++++----- .../java/datadog/common/queue/WorkQueues.java | 7 ++ 2 files changed, 86 insertions(+), 27 deletions(-) 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 index 5a413f7fcfb..bafdb4b95f8 100644 --- a/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedAdmissionBenchmark.java +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedAdmissionBenchmark.java @@ -1,12 +1,12 @@ 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.Group; -import org.openjdk.jmh.annotations.GroupThreads; import org.openjdk.jmh.annotations.Measurement; import org.openjdk.jmh.annotations.Mode; import org.openjdk.jmh.annotations.OutputTimeUnit; @@ -14,6 +14,7 @@ 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; @@ -53,25 +54,53 @@ * *

{@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. + * 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, filled in as they are measured: + *

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} on the same run is the control that says the load + * is not what put a microsecond on the other rows. * *

- * Benchmark                 (backings)   ns/op    B/op
- * refusedProducer           MPSC         ?        ?
- * refusedProducer           LINKED       ?        ?
- * refusedBuildThenOffer     -            ?        ?
- * refusedQueue              MPSC         ?        ?
- * refusedQueue              LINKED       ?        ?
- * refusedRaw                -            ?        ?
- * steady:produce            MPSC         ?        ?
- * steady:produce            LINKED       ?        ?
+ * Benchmark                 (backings)   ns/op            B/op
+ * refusedProducer           MPSC         1035.8 +- 239    0
+ * refusedProducer           LINKED       1162.5 +- 243    0
+ * refusedQueue              MPSC          963.7 +- 292    0
+ * refusedQueue              LINKED       1229.0 +-  57    0
+ * refusedBuildThenOffer     MPSC          448.9 +-  60    32
+ * refusedBuildThenOffer     LINKED        460.0 +-  16    32
+ * refusedRaw                MPSC            3.4 +-   1    0
+ * refusedRaw                LINKED          3.4 +-   1    0
+ * steady                    MPSC          798.7 +- 266    0
+ * steady                    LINKED       1008.4 +- 724    8.75
  * 
+ * + *

What this says, including the part that does not flatter the API. The permit counter is + * the dominant cost at the boundary, by two and a half orders of magnitude: a refused admission is + * ~960ns against ~3.4ns for the same rejection taken on jctools' own producer-index CAS. Eight + * threads doing two read-modify-writes on one shared line is the whole of that gap. On the MPSC + * backing that is being paid for a bound the ring was already enforcing for free. + * + *

And so the premise pair does not come out the way the module's argument wants. {@code + * refusedProducer} does hold 0 B/op where {@code refusedBuildThenOffer} pays 32 -- reserve-before- + * build does what it claims -- but it is slower in {@code ns/op}, ~1036 against ~449. Read the pair + * carefully before concluding anything from it: {@code refusedBuildThenOffer} offers to the raw + * queue, so it prices an allocation without a counter, while {@code refusedProducer} prices a + * counter without an allocation. It is not one variable. What the two together do establish is the + * ordering: under contention at the boundary, the counter costs more than the allocation it avoids. + * The allocation win is real and the contention cost is larger, and a call site that is refusing + * often is paying for reserve-before-build rather than being paid by it. + * + *

None of which is an argument against the API at a call site that mostly succeeds -- {@code + * steady} is the arm for that, and it allocates nothing on the MPSC backing against 8.75 B/op of + * linked node on the other. It is an argument for measuring the boundary before putting this in + * front of a producer that lives there. */ @Fork(2) @Warmup(iterations = 3, time = 1) @@ -135,6 +164,11 @@ static final class Payload { */ 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); @@ -151,6 +185,25 @@ public void setUp() { 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) { @@ -190,22 +243,21 @@ public void refusedRaw(Blackhole bh) { bh.consume(raw.offer(ELEMENT)); } - @Benchmark - @Group("steady") - @GroupThreads(3) - public void produce(Blackhole bh) { - bh.consume(queue.tryPut(ELEMENT)); - } - /** - * One consumer, because MPSC allows exactly one. Its own timing is not the point; it is here to - * keep {@link #produce} off the boundary and to put the counter's increment side under load at - * the same time as its decrement side. + * 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 - @Group("steady") - @GroupThreads(1) - public void consume(Blackhole bh) { - bh.consume(queue.process(CAPACITY, bh::consume)); + public void steady(Blackhole bh) { + bh.consume(queue.tryPut(ELEMENT)); } } 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 index 63ff73f7ffa..bc9e1b84146 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java @@ -21,6 +21,13 @@ private WorkQueues() {} * 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) { From 51fb1c32a54994583290a09361950a5e647f4206 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 22:15:47 -0400 Subject: [PATCH 31/48] Refuse with a load, and let the counter carry the closed state Two changes to admission, both aimed at the boundary that ContendedAdmissionBenchmark just priced at ~960ns per refusal against ~3.4ns for the same rejection on the backing's own producer index. A plain read now comes before the decrement. A refused claim used to pay two read-modify-writes on the one line every producer contends for, at the capacity boundary, which is where the most threads arrive at once. A full or closed queue now turns a claimant away with a load. The decrement stays authoritative, so the bound is untouched: the read can only cause a refusal, never an admission. The closed flag is gone, folded into the permit count as a large negative bias. The point is not that a volatile boolean load is expensive -- it is cheap -- but that the check disappears from all seven admission sites rather than getting cheaper, and that closed and capacity can no longer be observed out of step. A producer can no longer read an open flag and then claim a place that close() has already revoked, which is the survivor set shutdown()'s javadoc describes; it is now bounded by the counter instead of by two fields agreeing. The count is a long because an unbounded queue seeds it with Integer.MAX_VALUE, which leaves an int no room above the bound to put the bias -- close() on createUnboundedMpmcQueue would have silently done nothing. Six tests pin the encoding's three leak paths. Their javadoc is explicit that none of them currently catches its own slip, and why. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 94 ++++++++++++------- .../common/queue/WorkQueueContractTest.java | 59 ++++++++++++ 2 files changed, 121 insertions(+), 32 deletions(-) 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 index 11ff142f93c..e6b7be90244 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -7,14 +7,14 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.LongAdder; 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 flag, drop counting, and the consume-and-maybe-retry cycle. + * admission, reservations, the closed state, drop counting, 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 @@ -58,21 +58,41 @@ private static final class Retry { */ private final LongAdder dropped = new LongAdder(); - private volatile boolean closed; + /** + * 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); /** - * Places still available, not places used. 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. + * 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 AtomicInteger available; + private final AtomicLong state; private final int capacity; BaseWorkQueue(int capacity) { this.capacity = capacity; - this.available = new AtomicInteger(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; } /** @@ -102,6 +122,14 @@ private static final class Retry { * 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 @@ -109,15 +137,18 @@ private static final class Retry { * when it is already at the boundary, where the caller is dropping work regardless. */ private boolean claimPlace() { - if (available.decrementAndGet() >= 0) { + if (state.get() < 1) { + return false; + } + if (state.decrementAndGet() >= 0) { return true; } - available.incrementAndGet(); + state.incrementAndGet(); return false; } private void releasePlace() { - available.incrementAndGet(); + state.incrementAndGet(); } /** @@ -189,7 +220,7 @@ private boolean admitEach( C context, @Strategy BiContextualProducer producer, @Strategy RejectHandler onRejected) { - if (closed || !claimPlace()) { + if (!claimPlace()) { reject(element, onRejected); return false; } @@ -327,13 +358,13 @@ private void discardAll() { @Override public final int size() { // Claimants at the boundary can transiently drive the count below zero before backing out. - return Math.max(0, capacity - available.get()); + return (int) Math.max(0, capacity - permits(state.get())); } @Override public final boolean tryPut(T element) { requireElement(element); - if (closed || !admit(element)) { + if (!admit(element)) { dropped.increment(); return false; } @@ -343,18 +374,18 @@ public final boolean tryPut(T element) { @Override @SuppressWarnings({"unchecked", "rawtypes"}) public final boolean tryPut(Producer producer) { - return closed ? refuseClosed() : admit(producer, (ContextualProducer) PRODUCE); + return admit(producer, (ContextualProducer) PRODUCE); } @Override public final boolean tryPut(C context, ContextualProducer producer) { - return closed ? refuseClosed() : admit(context, producer); + return admit(context, producer); } @Override public final boolean tryPut( C1 first, C2 second, BiContextualProducer producer) { - return closed ? refuseClosed() : admit(first, second, producer); + return admit(first, second, producer); } @Override @@ -417,7 +448,7 @@ public final int tryPutBatch( @Override public final Reservation tryReserve() { - boolean granted = !closed && claimPlace(); + boolean granted = claimPlace(); if (!granted) { dropped.increment(); } @@ -561,7 +592,7 @@ private RetryQueue lease(int attempt) { public boolean retry(T item) { // No counting here. A refused retry is one step of a decision the strategy is still // making; the item is counted lost exactly once, when onFailure reports it gave up. - return !closed && admit(new Retry<>(item, attempt)); + return admit(new Retry<>(item, attempt)); } @Override @@ -576,15 +607,6 @@ public boolean retry(T... items) { }; } - /** - * A refusal that never reached a producer, so nothing was built and nothing could have been - * declined: the queue was already closed when the attempt began. - */ - private boolean refuseClosed() { - dropped.increment(); - return false; - } - /** * The one place the module says what a {@code null} element is. Neither backing can hold one, so * there is no outcome to report and nothing to count -- only a caller with a bug. Thrown before a @@ -603,12 +625,20 @@ public final long dropped() { @Override public final void close() { - closed = true; + 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 closed; + return state.get() < CLOSED_MARK; } @Override @@ -618,7 +648,7 @@ public final void clear() { @Override public final void shutdown() { - closed = true; + close(); discardAll(); } } 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 index 95d1f735ef9..61b2307f897 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -449,6 +449,65 @@ void unboundedQueueStillCloses() { 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. + * + *

These pin the behaviour; none of them currently catches its own implementation slip, and it + * is worth being straight about why. The offset is a multiple of 2^32, so {@code size()}'s cast + * back to {@code int} erases the bias whether or not the unbiasing is there; 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, which is when all three become reachable at once. + */ + @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( From c8cc491511bc79883f6208b8be4cc3dcdf746804 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 22:18:46 -0400 Subject: [PATCH 32/48] Record what the read bought at the boundary The javadoc asserted the counter was the dominant cost and that reserve-before- build lost on ns/op while winning on B/op. Both were true of the measurement and neither is true any more, so the file said the opposite of the truth. Refusal is ~7.9ns against ~2.9ns for jctools' own bound, so the counter costs about 5ns over a bound the ring already enforced, against ~960ns before. The premise pair has reversed with it: ~8ns and 0 B/op against ~422ns and 32. Attribution and doubt both recorded. The win is the relaxed read, not the folded closed flag -- a volatile boolean load cannot account for 950ns. And the ratio deserves more suspicion than the direction: 960ns is too expensive for two contended RMWs on a quiet machine, so a quiet run should show a smaller multiple against a smaller before. Co-Authored-By: Claude Opus 5 --- .../queue/ContendedAdmissionBenchmark.java | 75 +++++++++++-------- 1 file changed, 42 insertions(+), 33 deletions(-) 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 index bafdb4b95f8..4eca521f412 100644 --- a/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedAdmissionBenchmark.java +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedAdmissionBenchmark.java @@ -49,8 +49,9 @@ * 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. - * 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. + * 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 @@ -64,43 +65,51 @@ * *

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} on the same run is the control that says the load - * is not what put a microsecond on the other rows. + * 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)   ns/op            B/op
- * refusedProducer           MPSC         1035.8 +- 239    0
- * refusedProducer           LINKED       1162.5 +- 243    0
- * refusedQueue              MPSC          963.7 +- 292    0
- * refusedQueue              LINKED       1229.0 +-  57    0
- * refusedBuildThenOffer     MPSC          448.9 +-  60    32
- * refusedBuildThenOffer     LINKED        460.0 +-  16    32
- * refusedRaw                MPSC            3.4 +-   1    0
- * refusedRaw                LINKED          3.4 +-   1    0
- * steady                    MPSC          798.7 +- 266    0
- * steady                    LINKED       1008.4 +- 724    8.75
+ * Benchmark                 (backings)   before ns/op   after ns/op   B/op
+ * refusedProducer           MPSC             1035.8           8.2     0
+ * refusedProducer           LINKED           1162.5           9.0     0
+ * refusedQueue              MPSC              963.7           7.9     0
+ * refusedQueue              LINKED           1229.0           7.9     0
+ * refusedBuildThenOffer     MPSC              448.9         422.0     32
+ * refusedBuildThenOffer     LINKED            460.0         422.0     32
+ * refusedRaw                MPSC                3.4           2.9     0
+ * refusedRaw                LINKED              3.4           3.0     0
+ * steady                    MPSC              798.7         125.4     0
+ * steady                    LINKED           1008.4         146.8     1.4
  * 
* - *

What this says, including the part that does not flatter the API. The permit counter is - * the dominant cost at the boundary, by two and a half orders of magnitude: a refused admission is - * ~960ns against ~3.4ns for the same rejection taken on jctools' own producer-index CAS. Eight - * threads doing two read-modify-writes on one shared line is the whole of that gap. On the MPSC - * backing that is being paid for a bound the ring was already enforcing for free. + *

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.9ns against ~2.9ns 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. * - *

And so the premise pair does not come out the way the module's argument wants. {@code - * refusedProducer} does hold 0 B/op where {@code refusedBuildThenOffer} pays 32 -- reserve-before- - * build does what it claims -- but it is slower in {@code ns/op}, ~1036 against ~449. Read the pair - * carefully before concluding anything from it: {@code refusedBuildThenOffer} offers to the raw - * queue, so it prices an allocation without a counter, while {@code refusedProducer} prices a - * counter without an allocation. It is not one variable. What the two together do establish is the - * ordering: under contention at the boundary, the counter costs more than the allocation it avoids. - * The allocation win is real and the contention cost is larger, and a call site that is refusing - * often is paying for reserve-before-build rather than being paid by it. + *

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. * - *

None of which is an argument against the API at a call site that mostly succeeds -- {@code - * steady} is the arm for that, and it allocates nothing on the MPSC backing against 8.75 B/op of - * linked node on the other. It is an argument for measuring the boundary before putting this in - * front of a producer that lives there. + *

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) From 79b3c4099385deb172802411a0597bb55a2fc16e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 31 Aug 2026 11:46:12 -0400 Subject: [PATCH 33/48] Claim a batch's places in one go, clamped to what is there A batch used to claim one place per element, paying an atomic add per element at the boundary where the most threads are arriving. Claiming for a run of elements at once halves that, but a claim for a whole batch is two new ways to refuse: all-or-nothing, where one short place turns away a batch that mostly fit, and blast radius, where the dip the claim takes in the shared count makes every concurrent single admission see zero. The first goes away by construction. The claim asks for the smaller of what it wants and what the count already says is there, off the same relaxed read a single claim does, and refunds exactly the deficit -- so it grants what was available and can never refuse a batch that had room. The second is what the cap bounds, at a size chosen for how many neighbours one batcher may make refuse rather than for fairness. The loop stops only when a claim grants nothing. A short grant is not evidence the queue is full: claimants back out, and a declined producer element refunds its place while still spending the run's claim. Uncontended this is about 2ns per element slower -- the bookkeeping costs more than the atomic it removes. Contended it is 1.3x to 3.3x faster. Both tables are in the benchmarks. Co-Authored-By: Claude Opus 5 --- .../common/queue/BatchAdmissionBenchmark.java | 168 +++++++++++++ .../ContendedBatchAdmissionBenchmark.java | 231 ++++++++++++++++++ .../datadog/common/queue/BaseWorkQueue.java | 214 ++++++++++++++-- .../common/queue/WorkQueueContractTest.java | 109 +++++++++ 4 files changed, 698 insertions(+), 24 deletions(-) create mode 100644 utils/queue-utils/src/jmh/java/datadog/common/queue/BatchAdmissionBenchmark.java create mode 100644 utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedBatchAdmissionBenchmark.java 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..598f49b3f8b --- /dev/null +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/BatchAdmissionBenchmark.java @@ -0,0 +1,168 @@ +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    LINKED      4                83.2         20.8
+ * loopOfElements     LINKED      4                83.7         20.9
+ * batchOfElements    LINKED      32              660.3         20.6
+ * loopOfElements     LINKED      32              648.5         20.3
+ * batchOfElements    LINKED      128            2613.3         20.4
+ * loopOfElements     LINKED      128            2510.0         19.6
+ * 
+ * + *

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 a shade on LINKED. 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, + LINKED + } + + /** 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", "LINKED"}) + 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/ContendedBatchAdmissionBenchmark.java b/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedBatchAdmissionBenchmark.java new file mode 100644 index 00000000000..5f2201e3de2 --- /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         1009.5             240.7                 64.6
+ * MPSC        8    looping         1306.6             135.3                 81.4
+ * MPSC        32   batched         1991.5             162.3                 76.6
+ * MPSC        32   looping         5332.0             162.3                 78.6
+ * LINKED      8    batched         2375.7             483.1                 47.5
+ * LINKED      8    looping         4331.3             469.1                 38.5
+ * LINKED      32   batched         5643.3             426.1                 67.0
+ * LINKED      32   looping        18442.5             563.3                 28.1
+ * 
+ * + *

The producer column is the finding. Batching wins everywhere it is contended, by 1.3x + * at eight elements on MPSC and by 3.3x at thirty-two on LINKED, 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 -- + * LINKED neighbours refuse 67% next to a batcher against 28% next to a loop -- 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, + LINKED + } + + /** + * 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", "LINKED"}) + 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/main/java/datadog/common/queue/BaseWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java index e6b7be90244..04d19d95f3e 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -6,6 +6,7 @@ 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.concurrent.atomic.LongAdder; @@ -75,6 +76,21 @@ private static final class Retry { /** 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 @@ -151,6 +167,52 @@ 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); + } + } + /** * Counts nothing, unlike the producer admissions below. This one is shared with the retry path, * where a refusal is a step rather than an outcome: a strategy handed a refused retry may still @@ -205,25 +267,22 @@ private boolean admit( } /** - * The per-source-element half of {@link #tryPutBatch(Collection, Object, BiContextualProducer)}. + * 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, so it gives its place back and counts nothing. A refusal — no place to - * claim, or a backing that would not take what was produced — gives the place back where there is - * one and counts a drop. + * caller's own decision, so it gives its place back and counts nothing. A backing that would not + * take what was produced is a refusal: the place goes back and a drop is counted. 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 admitEach( + private boolean admitEachClaimed( E element, C context, @Strategy BiContextualProducer producer, @Strategy RejectHandler onRejected) { - if (!claimPlace()) { - reject(element, onRejected); - return false; - } T produced; try { produced = producer.produce(element, context); @@ -388,37 +447,111 @@ public final boolean tryPut( 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; - for (int i = 0; i < elements.length; i++) { - T element = elements[i]; - if (!tryPut(element)) { + 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) { - // 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. - rejected = new ArrayList<>(elements.length - i); + rejected = new ArrayList<>(length - index); + } + for (; index < length; index++) { + T element = elements[index]; + requireElement(element); + dropped.increment(); + rejected.add(element); + } + break; + } + int end = index + granted; + try { + for (; index < end; index++) { + T element = elements[index]; + requireElement(element); + if (!store(element)) { + releasePlace(); + dropped.increment(); + if (rejected == null) { + rejected = new ArrayList<>(length - index); + } + rejected.add(element); + } } - 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(); - for (T element : elements) { - if (!tryPut(element)) { + 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); } - rejected.add(element); + while (source.hasNext()) { + T element = source.next(); + requireElement(element); + dropped.increment(); + 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(); + dropped.increment(); + 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; } - remaining--; } return rejected == null ? emptyList() : rejected; } @@ -431,6 +564,13 @@ public final int tryPutBatch( 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, @@ -438,9 +578,35 @@ public final int tryPutBatch( BiContextualProducer producer, RejectHandler onRejected) { int admitted = 0; - for (E element : source) { - if (admitEach(element, context, producer, onRejected)) { - admitted++; + 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; 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 index 61b2307f897..49993111ace 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -1,5 +1,6 @@ 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; @@ -11,6 +12,7 @@ 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; @@ -820,6 +822,12 @@ void aNullElementThrowsOutOfABatchAndAbandonsTheRest( 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}") @@ -973,6 +981,107 @@ void aSuccessfulRetryCountsNothing(String name, IntFunction> f 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(0, queue.dropped()); + 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()); + assertEquals(10, queue.dropped()); + } + + @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"); + assertEquals(3, queue.dropped()); + } + + @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")); + } + private static List consumeAll(WorkQueue queue) { List consumed = new ArrayList<>(); while (queue.process(consumed::add)) { From 45a643d4d9766991a364016d803bc184a5f983fb Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 31 Aug 2026 11:46:15 -0400 Subject: [PATCH 34/48] Price the queue against what callers write instead The comparison exists whether or not we run it, so run it. The baselines are the two guards already in the tree -- WafMetricCollector's ArrayBlockingQueue(1024) and RumInjectorMetrics' LinkedBlockingQueue(1024), both of which build the element and then find out there was no room -- plus the hand-rolled counter in front of a ConcurrentLinkedQueue, and the raw ring as the floor. Admitting, the raw ring wins and nothing built on it will not; the API costs 12ns more and still comes in under both incumbents. Refusing, it is an order of magnitude apart, because it is the only one that has not already allocated by the time it asks. Co-Authored-By: Claude Opus 5 --- .../queue/AdmissionAlternativesBenchmark.java | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionAlternativesBenchmark.java 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)); + } +} From 932653d1728e914ed659641b85e2fe3c2fc906a2 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 31 Aug 2026 12:37:41 -0400 Subject: [PATCH 35/48] Back the multi-consumer queue with an array ring The linked queue was never the right structure for a bounded buffer; it was the only one whose refusals we could believe. JCTools' MPMC ring is not linearizable -- offer refuses, and poll reports empty, while another thread is midway through publishing to the slot in question, on a queue that is neither full nor empty. Measured at 0.24% of offers with four producers and four consumers on a ring of eight. For an ordinary caller that is disqualifying, because a refusal is ambiguous. Here it is not, because the bound does not live in the ring. A place is claimed before store is called, places outstanding never exceed capacity, and a place comes back only after the element has been retrieved. A thread that reaches store therefore holds a claim, a slot exists, and a refusal can only mean not yet -- so it retries. The same property that makes admission cheap, an authoritative counter in front of the structure, is what makes a lying structure safe behind it. The retry is bounded anyway: a wrong invariant should degrade to a counted drop, not a hang. Bounded and unbounded share one class rather than arriving as two types. store and retrieve are the sites every admission and drain funnels through, and a third implementation of them makes those megamorphic for callers that only ever touch one backing. A branch on a final field is much the cheaper way to hold two structures. What this buys is allocation, not latency: the per-element node is gone, and time is at parity. The 8ns the two structures differ by in isolation does not survive the admission machinery on top -- BackingOverhead prices the structures, BatchAdmission shows the delta vanishing. The benchmark tables that named a LINKED arm have been re-measured, since the arm they described no longer exists. Co-Authored-By: Claude Opus 5 --- .../queue/BackingOverheadBenchmark.java | 99 ++++++++++++++ .../common/queue/BatchAdmissionBenchmark.java | 27 ++-- .../queue/ContendedAdmissionBenchmark.java | 37 ++++-- .../ContendedBatchAdmissionBenchmark.java | 32 ++--- .../datadog/common/queue/BaseWorkQueue.java | 33 ++++- .../datadog/common/queue/LinkedWorkQueue.java | 39 ------ .../datadog/common/queue/MpmcWorkQueue.java | 123 ++++++++++++++++++ .../java/datadog/common/queue/Queues.java | 23 ++++ .../java/datadog/common/queue/WorkQueues.java | 25 ++-- .../common/queue/WorkQueueContractTest.java | 101 ++++++++++++++ 10 files changed, 449 insertions(+), 90 deletions(-) create mode 100644 utils/queue-utils/src/jmh/java/datadog/common/queue/BackingOverheadBenchmark.java delete mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java create mode 100644 utils/queue-utils/src/main/java/datadog/common/queue/MpmcWorkQueue.java 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 index 598f49b3f8b..03d8d796933 100644 --- a/utils/queue-utils/src/jmh/java/datadog/common/queue/BatchAdmissionBenchmark.java +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/BatchAdmissionBenchmark.java @@ -57,19 +57,28 @@ * loopOfElements MPSC 32 456.7 14.3 * batchOfElements MPSC 128 2084.0 16.3 * loopOfElements MPSC 128 1825.2 14.3 - * batchOfElements LINKED 4 83.2 20.8 - * loopOfElements LINKED 4 83.7 20.9 - * batchOfElements LINKED 32 660.3 20.6 - * loopOfElements LINKED 32 648.5 20.3 - * batchOfElements LINKED 128 2613.3 20.4 - * loopOfElements LINKED 128 2510.0 19.6 + * 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 a shade on LINKED. That + *

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 @@ -98,7 +107,7 @@ public class BatchAdmissionBenchmark { public enum Backings { MPSC, - LINKED + MPMC } /** Comfortably above the largest batch, so no arm is admitting at the bound. */ @@ -112,7 +121,7 @@ public enum Backings { private static final BiContextualProducer PRODUCER = (source, context) -> source; - @Param({"MPSC", "LINKED"}) + @Param({"MPSC", "MPMC"}) public Backings backings; @Param({"4", "8", "32", "128"}) 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 index 4eca521f412..25bcbc78883 100644 --- a/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedAdmissionBenchmark.java +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedAdmissionBenchmark.java @@ -73,21 +73,27 @@ * *

  * Benchmark                 (backings)   before ns/op   after ns/op   B/op
- * refusedProducer           MPSC             1035.8           8.2     0
- * refusedProducer           LINKED           1162.5           9.0     0
- * refusedQueue              MPSC              963.7           7.9     0
- * refusedQueue              LINKED           1229.0           7.9     0
- * refusedBuildThenOffer     MPSC              448.9         422.0     32
- * refusedBuildThenOffer     LINKED            460.0         422.0     32
- * refusedRaw                MPSC                3.4           2.9     0
- * refusedRaw                LINKED              3.4           3.0     0
- * steady                    MPSC              798.7         125.4     0
- * steady                    LINKED           1008.4         146.8     1.4
+ * 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.9ns against ~2.9ns for jctools' own producer-index CAS, so + * 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, @@ -99,6 +105,11 @@ * 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. @@ -122,7 +133,7 @@ public class ContendedAdmissionBenchmark { public enum Backings { MPSC, - LINKED + MPMC } /** Big enough that the steady arm is not living at the boundary by accident. */ @@ -154,7 +165,7 @@ static final class Payload { private static final Producer BUILDER = () -> new Payload(ELEMENT, ELEMENT, System.nanoTime()); - @Param({"MPSC", "LINKED"}) + @Param({"MPSC", "MPMC"}) public Backings backings; /** Never full, drained concurrently by {@link #consume}. */ 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 index 5f2201e3de2..4685918ae66 100644 --- a/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedBatchAdmissionBenchmark.java +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/ContendedBatchAdmissionBenchmark.java @@ -68,18 +68,18 @@ * *

  * (backings) (batchSize)   producer ns/op   neighbour ns/op   neighbour refused%
- * MPSC        8    batched         1009.5             240.7                 64.6
- * MPSC        8    looping         1306.6             135.3                 81.4
- * MPSC        32   batched         1991.5             162.3                 76.6
- * MPSC        32   looping         5332.0             162.3                 78.6
- * LINKED      8    batched         2375.7             483.1                 47.5
- * LINKED      8    looping         4331.3             469.1                 38.5
- * LINKED      32   batched         5643.3             426.1                 67.0
- * LINKED      32   looping        18442.5             563.3                 28.1
+ * 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 1.3x - * at eight elements on MPSC and by 3.3x at thirty-two on LINKED, and the advantage grows with the + *

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 @@ -87,10 +87,10 @@ * *

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 -- - * LINKED neighbours refuse 67% next to a batcher against 28% next to a loop -- 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. + * 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 @@ -109,7 +109,7 @@ public class ContendedBatchAdmissionBenchmark { public enum Backings { MPSC, - LINKED + MPMC } /** @@ -144,7 +144,7 @@ public void reset() { private static final String ELEMENT = "element"; - @Param({"MPSC", "LINKED"}) + @Param({"MPSC", "MPMC"}) public Backings backings; @Param({"8", "32"}) 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 index 04d19d95f3e..ef63dd6fcf9 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -123,7 +123,13 @@ private static long permits(long state) { * site is free; a third makes it megamorphic, measured at 24 bytes and roughly three times the * time per call — paid by callers that only ever touch one backing. A third backing is therefore * a decision about every existing caller, and the point at which to replace this template method - * with a per-caller strategy so the sites stay separate. + * with a per-caller strategy so the sites stay separate. It is also why a backing that wants to + * hold two structures branches on a field of its own rather than arriving here as two types. + * + *

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 is taken as a drop and + * counted, 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. */ abstract boolean store(Object element); @@ -132,6 +138,12 @@ private static long permits(long state) { */ 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 @@ -408,9 +420,24 @@ private Object take() { 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. A backing + * may report empty with an element in it — the MPMC ring does, for the width of another thread's + * publish — and stopping there would leave elements behind holding their places, which for {@link + * #clear} and {@link #shutdown} is the difference between emptying the queue and appearing to. + * The re-read is bounded, because {@code size} also counts places claimed by producers that have + * not stored yet, and a producer still running would otherwise keep this loop here forever. + */ private void discardAll() { - while (take() != null) { - // give every place back as it goes + int emptyReads = 0; + while (true) { + if (take() != null) { + emptyReads = 0; + } else if (size() <= 0 || ++emptyReads >= DRAIN_ATTEMPTS) { + return; + } } } diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java deleted file mode 100644 index 0cd4cec2808..00000000000 --- a/utils/queue-utils/src/main/java/datadog/common/queue/LinkedWorkQueue.java +++ /dev/null @@ -1,39 +0,0 @@ -package datadog.common.queue; - -import java.util.concurrent.ConcurrentLinkedQueue; - -/** - * A {@link WorkQueue} over a {@link ConcurrentLinkedQueue}: multi-producer, multi-consumer, - * optionally bounded. - * - *

This backing exists to give call sites that cannot yet take an MPSC ring — because they have - * several consumers, or no defensible capacity — the admission and lifecycle contract anyway, so - * they can be migrated behind {@link WorkQueue} first and re-backed later. It keeps the linked - * queue's per-element node, so it does not deliver the allocation win; prefer {@link - * MpscWorkQueue}. - * - *

Storage only: the bound lives in {@link BaseWorkQueue}, which is what replaces the hand-rolled - * cap plus O(n) {@code ConcurrentLinkedQueue.size()} walk such a call site otherwise pays on every - * admission, and makes {@link #size()} constant-time. - */ -final class LinkedWorkQueue extends BaseWorkQueue { - - private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); - - /** - * @param capacity the bound, or {@link Integer#MAX_VALUE} to leave the queue unbounded - */ - LinkedWorkQueue(int capacity) { - super(capacity); - } - - @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/MpmcWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/MpmcWorkQueue.java new file mode 100644 index 00000000000..ba3994a20ae --- /dev/null +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MpmcWorkQueue.java @@ -0,0 +1,123 @@ +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. + * + *

Why an array queue is usable here and not in general

+ * + *

The MPMC ring is not linearizable. Its {@code offer} refuses, and its {@code poll} reports + * empty, when another thread has claimed the slot they are looking at but has not yet published to + * it — on a queue that is neither full nor empty. Four producers and four consumers on a queue of + * eight produced a refusal-with-room on roughly 0.24% of offers. For an ordinary caller that is + * disqualifying, because a refusal is ambiguous: full, or not yet, with no way to tell them apart. + * + *

Here it is not ambiguous, because the bound does not live in the ring. A place is claimed + * before {@link #store} is ever called, and places outstanding never exceed capacity, and {@link + * BaseWorkQueue} gives a place back only after the element has been retrieved and the slot is + * already free. So a thread that reaches {@code store} holds a claim, a slot is free or is in the + * act of becoming free, and a refusal can only mean not yet. Retrying is therefore guaranteed to + * succeed, and the property that makes admission cheap — an authoritative counter in front of the + * structure — is the same property that makes a lying structure safe to sit behind it. + * + *

The retry is bounded anyway. If the accounting were ever wrong, an unbounded spin would turn a + * bug into a hang, and a hang is the failure mode this package has already been bitten by. Past the + * bound the element is dropped and counted, which is what an over-capacity admission does today. + */ +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/Queues.java b/utils/queue-utils/src/main/java/datadog/common/queue/Queues.java index 9c3de5fac8a..44ecf3035d7 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,27 @@ public static MessagePassingQueue mpscArrayQueue(int requestedCapacity) { return new MpscArrayQueue<>(requestedCapacity); } + /** + * Creates a Multiple Producer, Multiple Consumer (MPMC) array-backed queue. + * + *

Non-linearizable, and deliberately so: {@code offer} can refuse and {@code poll} can report + * empty while another thread is midway through publishing to the slot they are 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. + * + * @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/WorkQueues.java b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java index bc9e1b84146..1b2aff180fd 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java @@ -35,18 +35,19 @@ public static WorkQueue createMpscQueue(int requestedCapacity) { } /** - * Creates a bounded Multiple Producer, Multiple Consumer buffer backed by a {@link - * ConcurrentLinkedQueue}. + * Creates a bounded Multiple Producer, Multiple Consumer buffer backed by an MPMC array queue. * - *

For call sites that need several consumers. It keeps the linked queue's per-element node, so - * it buys the admission and lifecycle contract, an enforceable bound and a constant-time {@link - * WorkQueue#size()}, but not the allocation win — prefer {@link #createMpscQueue} where a single - * consumer is possible. + *

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 capacity the bound + * @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 capacity) { - return new LinkedWorkQueue<>(capacity); + public static WorkQueue createMpmcQueue(int requestedCapacity) { + return MpmcWorkQueue.bounded(requestedCapacity); } /** @@ -57,8 +58,12 @@ public static WorkQueue createMpmcQueue(int capacity) { * items abandoned by a retry strategy. 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 new LinkedWorkQueue<>(Integer.MAX_VALUE); + return MpmcWorkQueue.unbounded(); } } 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 index 49993111ace..9f89b3d913d 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -434,6 +434,107 @@ void shutdownClosesAndDiscards(String name, IntFunction> facto 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, and reports empty on a poll, 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 + // being counted as dropped, or their places never come back. + 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 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(); + } + } + }); + 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() + queue.dropped(), + "every element was either admitted or counted as dropped"); + 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(); From 74fb505497d8c8be2ac05765f02ef923270a1fad Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 31 Aug 2026 13:38:07 -0400 Subject: [PATCH 36/48] Measure the third-backing cliff instead of asserting it store and retrieve are one call site each, shared by every backing in the process, so their receiver profile is global -- a queue used nowhere near yours writes into it. The comment on store said a third implementation makes that site megamorphic and charges 24 bytes and three times the time per call, to callers that only ever touch one backing. Half of that is true. PrintInlining confirms the mechanism exactly: at three types C2 reports both as "failed to inline: virtual call", where at one and two it inlines them hot, on JDK 17 and JDK 25 alike. What it does not confirm is the price. One to two nanoseconds on a twenty-one nanosecond admit-and-drain, and no allocation difference at all -- because the operation is two uncontended atomics and a ring compare-and-set, and an out-of-line call is little against memory ordering. Contention widens the atomics and narrows this further. Batching does not help the argument either: the drain still returns a place per element. The one path that does care is admitting through a reservation, at roughly 30%, where the store sits at the end of a chain of optimizations that has to survive a call that stopped folding away. It is still scalar-replaced, so it is time and not garbage. So AdmissionBenchmark grows a THREE arm and a third backing to carry it, kept in the benchmark source set because shipping one would answer a different question. Its table had six rows of literal "?", which a reader has no way to tell from a measurement; those are filled in, and every arm now drives the same traffic through the shared sites so only the number of types in it varies -- the ONE arm previously skipped that loop entirely and so differed in more than its type count. Also drops a javadoc block that described the shared refusal singleton, a design this branch rejected. It had come loose from the field it described and settled on the drop counter, where it was simply wrong. Co-Authored-By: Claude Opus 5 --- .../common/queue/AdmissionBenchmark.java | 111 ++++++++++++------ .../common/queue/ThirdBackingWorkQueue.java | 36 ++++++ .../datadog/common/queue/BaseWorkQueue.java | 58 ++++++--- 3 files changed, 152 insertions(+), 53 deletions(-) create mode 100644 utils/queue-utils/src/jmh/java/datadog/common/queue/ThirdBackingWorkQueue.java 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 index 571ae4be1f9..8dd833bdd9a 100644 --- a/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionBenchmark.java +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/AdmissionBenchmark.java @@ -4,6 +4,7 @@ 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; @@ -28,32 +29,52 @@ * 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 ONE} loads a single - * concrete subclass, so {@code store} is monomorphic and C2 inlines it outright; {@code BOTH} loads - * two, which C2 still inlines behind a type guard. A third backing would be the cliff. Measuring - * both is how we find out whether the inheritance layout costs anything today, or only threatens - * to. + *

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, filled in as they are measured: + *

Results: * *

- * Benchmark                (backings)    ns/op    B/op
- * tryPutElement            ONE           ?        ?
- * tryPutElement            BOTH          ?        ?
- * tryPutContextual         ONE           ?        ?
- * tryPutContextual         BOTH          ?        ?
- * tryPutBiContextual       ONE           ?        ?
- * tryPutBiContextual       BOTH          ?        ?
- * reserveAndFill           ONE           20.4     0
- * reserveAndFill           BOTH          20.6     0
- * reserveRefused           ONE           13.8     0
- * reserveRefused           BOTH          13.9     0
- * reserveMixed             ONE           13.6     0     (12 with a shared refusal singleton)
- * reserveMixed             BOTH          13.6     0     (12 with a shared refusal singleton)
+ * 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 17, one machine, {@code -Pjmh.forks=1}. The single-outcome arms cannot distinguish the two - * refusal designs; only {@code reserveMixed} can. + *

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) @@ -66,7 +87,8 @@ public class AdmissionBenchmark { public enum Backings { ONE, - BOTH + BOTH, + THREE } private static final String ELEMENT = "element"; @@ -78,7 +100,7 @@ public enum Backings { private static final BiContextualProducer BI_CONTEXTUAL = (first, second) -> first; - @Param({"ONE", "BOTH"}) + @Param({"ONE", "BOTH", "THREE"}) public Backings backings; /** Alternates the reserving queue in {@link #reserveMixed}, so one site sees both outcomes. */ @@ -91,23 +113,44 @@ public enum Backings { private WorkQueue full; /** - * Present only to put a second concrete subclass into the profile. Its call sites are the same - * ones the queue under test uses, which is exactly the pollution being measured. + * 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 other; + private WorkQueue second; + + private WorkQueue third; @Setup - public void setUp(Blackhole bh) { + public void setUp() { queue = WorkQueues.createMpscQueue(1024); full = WorkQueues.createMpscQueue(1); full.tryPut(ELEMENT); - if (backings == Backings.BOTH) { - other = WorkQueues.createMpmcQueue(1024); - // Warm the other backing through the same methods, so both types reach the call sites. - for (int i = 0; i < 20_000; i++) { - other.tryPut(ELEMENT); - other.process(bh::consume); - } + 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); } } 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 index ef63dd6fcf9..5477453c362 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -48,14 +48,9 @@ private static final class Retry { private static final ContextualProducer, Object> PRODUCE = Producer::produce; /** - * The answer to every refused claim: a reservation that holds nothing, discards whatever is - * filled into it, and has nothing to give back. It holds no state, so one instance serves every - * queue and every element type. - * - *

Filling it is a no-op rather than a throw. The queue is full exactly when a caller can least - * afford a surprise, and an exception raised only under backpressure is a bug that waits for - * production to appear. The drop is already counted, by {@link #tryReserve} at the moment of - * refusal. + * Everything the queue lost, counted once each: refused admissions, elements a backing would not + * take, and items a retry strategy finally gave up on. Not a bound and not read on the admission + * path, so a {@link LongAdder}'s striping is free here and its contended write is what matters. */ private final LongAdder dropped = new LongAdder(); @@ -115,21 +110,46 @@ private static long permits(long 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. * - * @return whether the element was stored - */ - /** - * The one call site every backing funnels through, which is why the count of backings loaded in a - * process is an admission cost and not only a dispatch cost. At one or two implementations this - * site is free; a third makes it megamorphic, measured at 24 bytes and roughly three times the - * time per call — paid by callers that only ever touch one backing. A third backing is therefore - * a decision about every existing caller, and the point at which to replace this template method - * with a per-caller strategy so the sites stay separate. It is also why a backing that wants to - * hold two structures branches on a field of its own rather than arriving here as two types. - * *

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 is taken as a drop and * counted, 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); From 0b27ed22f7d92907153b92130dae950d71d879d8 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 31 Aug 2026 14:57:33 -0400 Subject: [PATCH 37/48] Give the place back when a backing refuses a filled element PlaceReservation.fill discarded store's return. That was safe while the only backing was MpscWorkQueue, whose store cannot fail once a place is in hand; MpmcWorkQueue's bounded retry made false reachable, and at that point the element vanished, the drop went uncounted, and the permit leaked for the life of the queue. Route fill through storeOrRelease, the same tail every other admission takes. The regression test needs a backing that refuses on demand, which no shipped one does, so it carries its own. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 10 +++-- .../common/queue/WorkQueueContractTest.java | 43 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) 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 index 5477453c362..628b379f8ba 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -348,7 +348,7 @@ private void reject(E element, @Strategy RejectHandler onRejected * The tail of every producer admission. A {@code null} is the producer declining, which is the * caller's own decision: the place goes back and nothing is counted, because nothing was lost. A * backing that would not take what was produced is a refusal, and is counted. Same three outcomes - * as {@link #admitEach}, which walks a source instead of taking one element. + * as {@link #admitEachClaimed}, which walks a source instead of taking one element. */ private boolean storeOrRelease(T element) { if (element == null) { @@ -419,7 +419,11 @@ public void fill(T element) { } requireElement(element); done = true; - queue.store(element); + // 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, once, counted. Discarding this return was + // safe only while no backing could refuse an element it had already claimed room for. + queue.storeOrRelease(element); } @Override @@ -798,7 +802,7 @@ private void consume( } } - /** Allocated only once a consumer has thrown, and never escapes {@link #onFailure}. */ + /** Allocated only once a consumer has thrown. See {@link RetryStrategy#onFailure}. */ private RetryQueue lease(int attempt) { return new RetryQueue() { @Override 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 index 9f89b3d913d..4314b27ab9a 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -1183,6 +1183,49 @@ void batchesAndSingleAdmissionsRacingCannotBetweenThemPassTheBound( 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 aRefusedFillGivesThePlaceBackAndCountsTheDrop() { + 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"); + assertEquals(1, queue.dropped(), "the lost element was not counted"); + // 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 aRefusedStoreOnAPlainPutIsCountedOnce() { + RefusingWorkQueue queue = new RefusingWorkQueue<>(1); + assertFalse(queue.tryPut("lost")); + assertEquals(0, queue.size()); + assertEquals(1, queue.dropped(), "a refusal counted more or less than once"); + } + private static List consumeAll(WorkQueue queue) { List consumed = new ArrayList<>(); while (queue.process(consumed::add)) { From 4bba350bcf5727f82076e4c2e5a3d458a72d8809 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 31 Aug 2026 15:11:13 -0400 Subject: [PATCH 38/48] Measure what the retry lease actually costs lease() builds a RetryQueue per failure, and the javadoc claimed it never escapes -- a claim about well-behaved strategies, not about what the compiler can prove. RetryLeaseBenchmark asks the compiler. It costs nothing in every shape but one: a strategy that is not a constant, at a call site that has gone megamorphic, pays 24 bytes per failure. A static final strategy pays nothing at any number of loaded types, because a constant receiver devirtualizes by resolution and the polluted profile is never consulted -- which is why the first cut of this benchmark could not find the case at all. The comment on lease() now says which of those a reader is in, and why the cheaper-looking field-held lease is not available: one lease per queue is one mutable attempt number, and createMpmcQueue means several consumers can be failing at once. Co-Authored-By: Claude Opus 5 --- .../common/queue/RetryLeaseBenchmark.java | 195 ++++++++++++++++++ .../datadog/common/queue/BaseWorkQueue.java | 21 +- 2 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 utils/queue-utils/src/jmh/java/datadog/common/queue/RetryLeaseBenchmark.java 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..ff8f7b41315 --- /dev/null +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/RetryLeaseBenchmark.java @@ -0,0 +1,195 @@ +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                   0     0      0       22.1
+ * failsAndGivesUpVirtual            0     0     24       22.4
+ * failsAndRetries                  24    24     24       41.6
+ * failsWithHandler (control)        0     0      0       22.5
+ * 
+ * + *

JDK 25, {@code -Pjmh.forks=2}. Two separate things are visible, and the first was not the one + * being looked for. + * + *

A {@code static final} strategy never pays for the lease, at any number of loaded types. + * {@code failsAndGivesUp} reads zero at {@code FOUR}, where the receiver profile is thoroughly + * polluted, because the profile is not what C2 consulted: the field is a constant, so the exact + * receiver class is known outright and {@code onFailure} devirtualizes by static resolution. {@code + * failsAndGivesUpVirtual} is the identical strategy behind a non-final field, and that arm does + * move -- zero while the site stays bimorphic, 24 bytes once it does not. Those 24 bytes are the + * lease: an object header, the captured queue, and the captured attempt number. + * + *

{@code failsAndRetries} allocates 24 bytes 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. {@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 in the virtual arm are the lease rather than something else on the failure path. + * + *

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; + + @Setup + public void setUp(Blackhole bh) { + queue = WorkQueues.createMpscQueue(1024); + bound = GIVE_UP; + 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 control: the same throw down the handler branch, which is never handed a lease. */ + @Benchmark + public boolean failsWithHandler() { + queue.tryPut(ELEMENT); + return queue.processOrHandle(THROWS, HANDLER); + } +} diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java index 628b379f8ba..01de2e1029b 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -802,7 +802,26 @@ private void consume( } } - /** Allocated only once a consumer has thrown. See {@link RetryStrategy#onFailure}. */ + /** + * 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. {@code RetryLeaseBenchmark} measures 0 B/op wherever C2 can + * inline {@link RetryStrategy#onFailure} and see that the lease does not escape, which covers a + * strategy in a {@code static final} field at any number of loaded strategy types -- a constant + * receiver devirtualizes by resolution and never consults the profile -- and a strategy behind a + * mutable field while that call site stays bimorphic. It costs 24 bytes in the remaining case: a + * non-constant strategy at a megamorphic call site. That is 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 From c8500b4a8279ac30ab5115ebd201d8b5264c0066 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 31 Aug 2026 15:22:03 -0400 Subject: [PATCH 39/48] Price the strategy binding, not just the lease One strategy object, four ways of reaching it, at one, two and four loaded implementations. Three cost nothing at any type count: a static final field, an inline lambda, and -- the one worth knowing -- an inline capturing lambda, whose allocation site tells C2 the exact class even though nothing folds to a constant. The fourth, an ordinary instance field, is the only shape that pays, and it pays exactly where the receiver profile stops being able to answer. So the rule is not "hoist the strategy into a field". A plain final instance field is the failing case, because HotSpot does not trust non-static finals and a reader cannot tell. Co-Authored-By: Claude Opus 5 --- .../common/queue/RetryLeaseBenchmark.java | 82 ++++++++++++++----- .../datadog/common/queue/BaseWorkQueue.java | 17 ++-- 2 files changed, 71 insertions(+), 28 deletions(-) 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 index ff8f7b41315..403295c36e5 100644 --- a/utils/queue-utils/src/jmh/java/datadog/common/queue/RetryLeaseBenchmark.java +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/RetryLeaseBenchmark.java @@ -40,30 +40,46 @@ *

Results: * *

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

JDK 25, {@code -Pjmh.forks=2}. Two separate things are visible, and the first was not the one - * being looked for. + *

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

A {@code static final} strategy never pays for the lease, at any number of loaded types. - * {@code failsAndGivesUp} reads zero at {@code FOUR}, where the receiver profile is thoroughly - * polluted, because the profile is not what C2 consulted: the field is a constant, so the exact - * receiver class is known outright and {@code onFailure} devirtualizes by static resolution. {@code - * failsAndGivesUpVirtual} is the identical strategy behind a non-final field, and that arm does - * move -- zero while the site stays bimorphic, 24 bytes once it does not. Those 24 bytes are the - * lease: an object header, the captured queue, and the captured attempt number. + *

What the strategy is bound to decides everything. Four of the five failure arms run the + * same strategy and differ only in how the call site reaches it. Three of them cost nothing at any + * number of loaded implementations: a {@code static final} field, which is a trusted constant; an + * inline non-capturing lambda, whose {@code invokedynamic} links through a {@code ConstantCallSite} + * and folds to the same thing without a field to declare; and -- the surprise -- an inline + * capturing lambda, which is not a constant at all but whose allocation site is right there + * in the caller, so C2 knows the exact type anyway and scalar-replaces the capture. What they share + * is that C2 knows the receiver's exact class without asking the profile. Only {@code + * failsAndGivesUpVirtual}, which loads the identical object from a non-final instance field, has to + * ask -- and at {@code FOUR} the profile has nothing useful to say. * - *

{@code failsAndRetries} allocates 24 bytes 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. {@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 in the virtual arm are the lease rather than something else on the failure path. + *

So the discipline is not "hoist the strategy into a field"; a plain {@code final} instance + * field is the one shape here that fails, because HotSpot does not trust it ({@code + * TrustFinalNonStaticFields} is off) and a human reads it as bound-once regardless. The capturing + * arm's zero is the narrower claim of the two: the capture is free because everything around it + * inlined, and the same lambda stored into a field and read back later would allocate. + * + *

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. @@ -138,6 +154,9 @@ public boolean onFailure(String i, int attempt, Throwable f, RetryQueue private WorkQueue other; + /** Captured by {@link #failsAndGivesUpCapturing}, so that lambda cannot be hoisted. */ + private int mixer; + @Setup public void setUp(Blackhole bh) { queue = WorkQueues.createMpscQueue(1024); @@ -186,6 +205,29 @@ public boolean failsAndGivesUpVirtual() { 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); + } + /** The control: the same throw down the handler branch, which is never handed a lease. */ @Benchmark public boolean failsWithHandler() { 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 index 01de2e1029b..4f1307f9aaa 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -813,14 +813,15 @@ private void consume( * parameter every strategy must thread through whether it cares or not -- or paying for this * object. * - *

Usually it does not get paid. {@code RetryLeaseBenchmark} measures 0 B/op wherever C2 can - * inline {@link RetryStrategy#onFailure} and see that the lease does not escape, which covers a - * strategy in a {@code static final} field at any number of loaded strategy types -- a constant - * receiver devirtualizes by resolution and never consults the profile -- and a strategy behind a - * mutable field while that call site stays bimorphic. It costs 24 bytes in the remaining case: a - * non-constant strategy at a megamorphic call site. That is 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. + *

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 for a {@code static final} strategy, for an inline lambda, + * and for an inline capturing lambda, at any number of loaded strategy types. The one shape that + * pays is a strategy loaded from an ordinary field -- {@code final} does not help, HotSpot does + * not trust non-static finals -- reaching a call site that has gone megamorphic. 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() { From 95005d07409a0689e9177416713851be3daa4e9a Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 31 Aug 2026 15:31:36 -0400 Subject: [PATCH 40/48] Finish the strategy binding matrix with the exact-typed field The interface-typed field is not "fields lose". A field declared at a concrete final class reads 0 B/op at four loaded implementations, from a field left deliberately non-final: the declared type gives C2 the exact klass and the value never has to be trusted. That is the only route open to a per-instance strategy, since folding this.strategy would need the holder to be a constant as well, and it is the one route a lambda cannot take, its class being unnameable. Shared policy gets static final or an inline lambda; per-instance policy gets a named final class. The failing arm is the first shape used for the second situation. Co-Authored-By: Claude Opus 5 --- .../common/queue/RetryLeaseBenchmark.java | 86 ++++++++++++++----- .../datadog/common/queue/BaseWorkQueue.java | 12 +-- 2 files changed, 69 insertions(+), 29 deletions(-) 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 index 403295c36e5..6dd3a0fbfbc 100644 --- a/utils/queue-utils/src/jmh/java/datadog/common/queue/RetryLeaseBenchmark.java +++ b/utils/queue-utils/src/jmh/java/datadog/common/queue/RetryLeaseBenchmark.java @@ -40,35 +40,44 @@ *

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.5
- * failsAndGivesUpInline (lambda)      0     0      0       22.6
- * failsAndGivesUpCapturing            0     0      0       21.1
- * failsAndGivesUpVirtual (field)      0     0     24       21.8
- * failsAndRetries                    24    24     24       41.6
- * failsWithHandler (control)          0     0      0       22.5
+ * 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. Four of the five failure arms run the - * same strategy and differ only in how the call site reaches it. Three of them cost nothing at any - * number of loaded implementations: a {@code static final} field, which is a trusted constant; an - * inline non-capturing lambda, whose {@code invokedynamic} links through a {@code ConstantCallSite} - * and folds to the same thing without a field to declare; and -- the surprise -- an inline - * capturing lambda, which is not a constant at all but whose allocation site is right there - * in the caller, so C2 knows the exact type anyway and scalar-replaces the capture. What they share - * is that C2 knows the receiver's exact class without asking the profile. Only {@code - * failsAndGivesUpVirtual}, which loads the identical object from a non-final instance field, has to - * ask -- and at {@code FOUR} the profile has nothing useful to say. + *

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. * - *

So the discipline is not "hoist the strategy into a field"; a plain {@code final} instance - * field is the one shape here that fails, because HotSpot does not trust it ({@code - * TrustFinalNonStaticFields} is off) and a human reads it as bound-once regardless. The capturing - * arm's zero is the narrower claim of the two: the capture is free because everything around it - * inlined, and the same lambda stored into a field and read back later would allocate. + *

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}, @@ -154,6 +163,25 @@ public boolean onFailure(String i, int attempt, Throwable f, RetryQueue 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; @@ -161,6 +189,7 @@ public boolean onFailure(String i, int attempt, Throwable f, RetryQueue 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. @@ -228,6 +257,17 @@ public boolean failsAndGivesUpCapturing() { 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() { 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 index 4f1307f9aaa..db6b2064ace 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -816,12 +816,12 @@ private void consume( *

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 for a {@code static final} strategy, for an inline lambda, - * and for an inline capturing lambda, at any number of loaded strategy types. The one shape that - * pays is a strategy loaded from an ordinary field -- {@code final} does not help, HotSpot does - * not trust non-static finals -- reaching a call site that has gone megamorphic. 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. + * 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() { From 43add18cb56a06969d077aacb13f153a4a24bedd Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 31 Aug 2026 16:45:16 -0400 Subject: [PATCH 41/48] Say which hand-rolled failures this API deletes The motivation was "callers duplicate a capacity check and a drop counter", which is the weak form. The duplication is not the cost; it is where the stability gaps come from. So the javadoc now carries the list of admission failures actually present in this tree, each against what this API does about it -- and says plainly which become impossible, which becomes merely unlikely, and which is only made explicit. Three of them are in one class. FlagEvaluationWriterImpl is cited as the counter-example, because it gets all of this right by hand and that is the point: one team derived it and wrote it down, which is the work this module pays for once. Co-Authored-By: Claude Opus 5 --- .../java/datadog/common/queue/WorkQueue.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) 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 index 3c61087b900..50ffbbea96d 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -10,6 +10,73 @@ * 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, and one it only makes + * explicit. + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Hand-rolled admission failures, and what this API does about them
FailureSeen asHere
The {@code offer} return is discarded, so a drop is invisibleSeven call sites in {@code RumInjectorMetrics}; two in {@code WafMetricCollector}; + * {@code ProductChangeCollector}; {@code IntegrationsCollector}Impossible. The count lives in the queue, not at the call site, so ignoring the + * return of {@code tryPut} still leaves the loss on {@link #dropped}
The same queue is handled two ways in one class{@code WafMetricCollector} tests the return at a dozen sites and drops it at twoImpossible. Consistency is not a per-site discipline when the queue counts
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. + * *

Capacity is fixed by construction. A queue never grows in response to fullness: full means * drop and count. 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 From 888919f715ab9e7ddad71955ee137369c4d23a88 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 13:42:05 -0400 Subject: [PATCH 42/48] Let the caller's own return value be the record of a refusal WorkQueue owned a dropped() counter over every loss: refused admissions, elements a backing would not take, items a retry strategy gave up on. Ten of its eleven increment sites duplicated something the caller was already told synchronously -- tryPut's boolean, tryPutBatch's admitted count, Reservation.granted(), the RejectHandler -- and nothing in the tree read the counter, so it was a safety net for a caller ignoring returns rather than information the caller could not otherwise get. The eleventh site is the exception and is left documented rather than fixed: an item a RetryStrategy abandons is now lost with no report to the caller, because processOrRetry returns only whether there was an item. It wants either a changed return there or the counter back, in its own PR. The stress tests now have producers count their own refusals, so the conservation invariants check admitted + refused against what callers were actually told -- the return value the API expects them to read. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/BaseWorkQueue.java | 83 ++++-------- .../java/datadog/common/queue/RetryQueue.java | 9 +- .../java/datadog/common/queue/WorkQueue.java | 51 ++++--- .../java/datadog/common/queue/WorkQueues.java | 7 +- .../common/queue/MpscWorkQueueStressTest.java | 25 ++-- .../common/queue/WorkQueueContractTest.java | 128 ++++++------------ 6 files changed, 118 insertions(+), 185 deletions(-) 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 index db6b2064ace..259b9ea894c 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -9,13 +9,12 @@ import java.util.Iterator; import java.util.List; import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.LongAdder; 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, drop counting, and the consume-and-maybe-retry cycle. + * 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 @@ -47,13 +46,6 @@ private static final class Retry { /** Non-capturing adapters, so the producer forms share one admission path without allocating. */ private static final ContextualProducer, Object> PRODUCE = Producer::produce; - /** - * Everything the queue lost, counted once each: refused admissions, elements a backing would not - * take, and items a retry strategy finally gave up on. Not a bound and not read on the admission - * path, so a {@link LongAdder}'s striping is free here and its contended write is what matters. - */ - private final LongAdder dropped = new LongAdder(); - /** * 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 @@ -111,9 +103,9 @@ private static long permits(long state) { * 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 is taken as a drop and - * counted, 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. + * 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

* @@ -246,10 +238,9 @@ private void releasePlaces(int places) { } /** - * Counts nothing, unlike the producer admissions below. This one is 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. Each caller counts its own outcome, once. + * 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()) { @@ -266,7 +257,6 @@ private boolean admit(Object element) { private boolean admit( C context, @Strategy ContextualProducer producer) { if (!claimPlace()) { - dropped.increment(); return false; } T element; @@ -285,7 +275,6 @@ private boolean admit( C2 second, @Strategy BiContextualProducer producer) { if (!claimPlace()) { - dropped.increment(); return false; } T element; @@ -303,9 +292,10 @@ private boolean admit( * 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, so it gives its place back and counts nothing. A backing that would not - * take what was produced is a refusal: the place goes back and a drop is counted. The third way - * to fail -- no place at all -- cannot arise here, because the caller does not enter without one. + * 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 */ @@ -323,7 +313,7 @@ private boolean admitEachClaimed( throw t; } if (produced == null) { - // Declined. The place goes back and nothing is counted, because nothing was lost. + // Declined. The place goes back, and no handler is told, because nothing was lost. releasePlace(); return false; } @@ -338,7 +328,6 @@ private boolean admitEachClaimed( /** {@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) { - dropped.increment(); if (onRejected != null) { onRejected.onRejected(element); } @@ -346,9 +335,9 @@ private void reject(E element, @Strategy RejectHandler onRejected /** * The tail of every producer admission. A {@code null} is the producer declining, which is the - * caller's own decision: the place goes back and nothing is counted, because nothing was lost. A - * backing that would not take what was produced is a refusal, and is counted. Same three outcomes - * as {@link #admitEachClaimed}, which walks a source instead of taking one element. + * 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) { @@ -359,7 +348,6 @@ private boolean storeOrRelease(T element) { return true; } releasePlace(); - dropped.increment(); return false; } @@ -421,7 +409,7 @@ public void fill(T 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, once, counted. Discarding this return was + // 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); } @@ -474,11 +462,7 @@ public final int size() { @Override public final boolean tryPut(T element) { requireElement(element); - if (!admit(element)) { - dropped.increment(); - return false; - } - return true; + return admit(element); } @Override @@ -528,7 +512,6 @@ public final Collection tryPutBatch(T... elements) { for (; index < length; index++) { T element = elements[index]; requireElement(element); - dropped.increment(); rejected.add(element); } break; @@ -540,7 +523,6 @@ public final Collection tryPutBatch(T... elements) { requireElement(element); if (!store(element)) { releasePlace(); - dropped.increment(); if (rejected == null) { rejected = new ArrayList<>(length - index); } @@ -572,7 +554,6 @@ public final Collection tryPutBatch(Collection elements) { while (source.hasNext()) { T element = source.next(); requireElement(element); - dropped.increment(); rejected.add(element); } break; @@ -586,7 +567,6 @@ public final Collection tryPutBatch(Collection elements) { remaining--; if (!store(element)) { releasePlace(); - dropped.increment(); if (rejected == null) { rejected = new ArrayList<>(remaining + 1); } @@ -665,11 +645,7 @@ public final int tryPutBatch( @Override public final Reservation tryReserve() { - boolean granted = claimPlace(); - if (!granted) { - dropped.increment(); - } - return new PlaceReservation<>(this, granted); + return new PlaceReservation<>(this, claimPlace()); } @Override @@ -751,6 +727,7 @@ private int process( } // 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); } @@ -794,10 +771,13 @@ private void consume( } } catch (Throwable failure) { if (exceptionHandler != null) { - dropped.increment(); exceptionHandler.handle(item, failure); - } else if (!retryStrategy.onFailure(item, attempt + 1, failure, lease(attempt + 1))) { - dropped.increment(); + } 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)); } } } @@ -827,8 +807,8 @@ private RetryQueue lease(int attempt) { return new RetryQueue() { @Override public boolean retry(T item) { - // No counting here. A refused retry is one step of a decision the strategy is still - // making; the item is counted lost exactly once, when onFailure reports it gave up. + // 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)); } @@ -846,8 +826,8 @@ public boolean retry(T... items) { /** * The one place the module says what a {@code null} element is. Neither backing can hold one, so - * there is no outcome to report and nothing to count -- only a caller with a bug. Thrown before a - * place is claimed, so a rejected call costs the queue nothing. + * 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) { @@ -855,11 +835,6 @@ private static void requireElement(Object element) { } } - @Override - public final long dropped() { - return dropped.sum(); - } - @Override public final void close() { long current; 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 index feba695044a..d7c1815b60a 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java @@ -11,11 +11,10 @@ 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 is not itself - * counted as a drop: the item is counted once, when {@link RetryStrategy#onFailure} returns - * {@code false} to say the strategy gave up. A strategy that cannot resubmit must therefore - * report that, or the item is lost without being counted. This is the overload every ordinary - * strategy wants: it resubmits without allocating the array the varargs form needs. + * 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. * * @return whether the item was resubmitted */ 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 index 50ffbbea96d..f83d70c11e9 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -15,23 +15,24 @@ * 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, and one it only makes - * explicit. + * one: this API makes some of them impossible, some merely unlikely, one it only makes explicit, + * and one it does not address at all. * * * * * - * - * - * - * - * - * - * - * + * + * + * * * * @@ -75,12 +76,14 @@ *

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. + * 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 - * drop and count. 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. + * 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 @@ -109,8 +112,8 @@ * 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, nothing is admitted, and - * nothing is counted against {@link #dropped}, because a decision is not a loss. + * 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 @@ -173,15 +176,15 @@ boolean tryPut( * 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 is not counted against {@link - * #dropped()} and does not count as admitted; the place claimed for it is simply given back. + * 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 — and counts against {@link #dropped()} — some of each. + * 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 @@ -325,12 +328,6 @@ boolean processOrHandle( int size(); - /** - * @return how many elements have been rejected on admission, or abandoned by a {@link - * RetryStrategy}, over this queue's lifetime - */ - long dropped(); - /** * Stops future admission, leaving current contents alone so a consumer can finish its backlog. * 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 index 1b2aff180fd..bda2d207f54 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueues.java @@ -54,10 +54,9 @@ public static WorkQueue createMpmcQueue(int requestedCapacity) { * Creates an unbounded Multiple Producer, Multiple Consumer buffer backed by a {@link * ConcurrentLinkedQueue}. * - *

Unbounded means admission never rejects and {@link WorkQueue#dropped()} only ever counts - * items abandoned by a retry strategy. 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}. + *

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} 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 index 18b29593d9f..0171b5ec56a 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/MpscWorkQueueStressTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/MpscWorkQueueStressTest.java @@ -16,9 +16,10 @@ * 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 element it was told was rejected must be counted as - * dropped — so admitted plus dropped accounts for everything offered, with nothing lost, duplicated - * or invented in between. + * 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 { @@ -33,6 +34,7 @@ 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); @@ -49,6 +51,8 @@ void conservesEveryElementUnderContention() throws Exception { int value = producer * PER_PRODUCER + i; if (queue.tryPut(value)) { admitted.incrementAndGet(); + } else { + refused.incrementAndGet(); } } } finally { @@ -91,7 +95,8 @@ void conservesEveryElementUnderContention() throws Exception { assertEquals( admitted.get(), consumed.get(), "every admitted element reaches the consumer once"); - assertEquals(TOTAL - admitted.get(), queue.dropped(), "every rejection is counted"); + assertEquals( + TOTAL - admitted.get(), refused.get(), "every rejection was reported to its producer"); assertEquals(0, queue.size()); for (int value = 0; value < TOTAL; value++) { @@ -110,10 +115,8 @@ void neverInvokesProducerWhileFull() throws Exception { while (queue.tryPut(0)) { // fill it, and leave it full — nothing consumes } - // the loop above ends on a rejection, which is itself a drop - long droppedWhileFilling = queue.dropped(); - AtomicInteger produced = new AtomicInteger(); + AtomicInteger refusedAfterFull = new AtomicInteger(); AtomicInteger admittedAfterFull = new AtomicInteger(); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(PRODUCERS); @@ -134,6 +137,8 @@ void neverInvokesProducerWhileFull() throws Exception { }); if (landed) { admittedAfterFull.incrementAndGet(); + } else { + refusedAfterFull.incrementAndGet(); } } } finally { @@ -151,9 +156,9 @@ void neverInvokesProducerWhileFull() throws Exception { 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( - droppedWhileFilling + (long) PRODUCERS * PER_PRODUCER, - queue.dropped(), - "every rejected admission is counted"); + (long) PRODUCERS * PER_PRODUCER, + refusedAfterFull.get(), + "every rejected admission was reported to its producer"); } /** 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 index 4314b27ab9a..45f910a12ba 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -34,7 +34,7 @@ static Stream boundedQueues() { @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void admitsUpToCapacityThenDrops(String name, IntFunction> factory) { + void admitsUpToCapacityThenRefuses(String name, IntFunction> factory) { WorkQueue queue = factory.apply(CAPACITY); for (int i = 0; i < CAPACITY; i++) { assertTrue(queue.tryPut("e" + i)); @@ -42,7 +42,6 @@ void admitsUpToCapacityThenDrops(String name, IntFunction> fac assertEquals(CAPACITY, queue.size()); assertFalse(queue.tryPut("overflow")); assertEquals(CAPACITY, queue.size()); - assertEquals(1, queue.dropped()); } /** The point of the whole API: a rejected element is never built. */ @@ -79,7 +78,6 @@ void batchAdmissionReportsRejectedElements(String name, IntFunction queue = factory.apply(CAPACITY); Collection rejected = queue.tryPutBatch("a", "b", "c", "d", "e", "f"); assertEquals(Arrays.asList("e", "f"), new ArrayList<>(rejected)); - assertEquals(2, queue.dropped()); } @ParameterizedTest(name = "{0}") @@ -89,7 +87,6 @@ void collectionAdmissionReportsRejectedElements( 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)); - assertEquals(2, queue.dropped()); } @ParameterizedTest(name = "{0}") @@ -101,16 +98,14 @@ void transformingBatchAdmissionAppliesTheContextToEverySourceElement( queue.tryPutBatch(Arrays.asList(1, 2, 3), "x", (source, suffix) -> source + suffix); assertEquals(3, admitted); assertEquals(Arrays.asList("1x", "2x", "3x"), consumeAll(queue)); - assertEquals(0, queue.dropped()); } @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void aDeclinedSourceElementIsNeitherAdmittedNorDropped( - String name, IntFunction> factory) { + 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 counts - // against neither the admitted total nor dropped(): the caller already knows it declined. + // 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), @@ -118,7 +113,6 @@ void aDeclinedSourceElementIsNeitherAdmittedNorDropped( (source, suffix) -> source % 2 == 0 ? null : source + suffix); assertEquals(3, admitted); assertEquals(Arrays.asList("1x", "3x", "5x"), consumeAll(queue)); - assertEquals(0, queue.dropped()); } @ParameterizedTest(name = "{0}") @@ -136,7 +130,6 @@ void decliningLeavesTheClaimedPlaceAvailableToTheRestOfTheBatch( (source, suffix) -> source % 2 == 0 ? null : source + suffix); assertEquals(CAPACITY, admitted); assertEquals(Arrays.asList("1x", "3x", "5x", "7x"), consumeAll(queue)); - assertEquals(0, queue.dropped()); } @ParameterizedTest(name = "{0}") @@ -159,26 +152,31 @@ void theShortfallIsExactWhenTheCallerKnowsWhatItMeantToAdmit( 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); - assertEquals(2, queue.dropped()); } @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void aSourceElementTheProducerWouldHaveDeclinedIsStillDroppedOnceFull( + 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 counts what is true from where it stands: it could not ask. - // This is why dropped() is approximate for a declining producer and the shortfall is not. + // 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); + (source, suffix) -> source % 2 == 0 ? null : source + suffix, + refused::add); assertEquals(CAPACITY, admitted); assertEquals(Arrays.asList("1x", "3x", "5x", "7x"), consumeAll(queue)); - assertEquals(1, queue.dropped()); + assertEquals( + Arrays.asList(8), + refused, + "element 8 was refused for want of a place, though it would have been declined"); } @ParameterizedTest(name = "{0}") @@ -198,7 +196,6 @@ void transformingBatchAdmissionAdmitsNothingOnceClosed( }); assertEquals(0, admitted); assertFalse(asked.get(), "a closed queue must not ask the producer for anything"); - assertEquals(2, queue.dropped()); } @ParameterizedTest(name = "{0}") @@ -215,7 +212,6 @@ void aRejectHandlerSeesEverySourceElementThatCouldNotBeAdmitted( rejected::add); assertEquals(CAPACITY, admitted); assertEquals(Arrays.asList(5, 6), rejected); - assertEquals(2, queue.dropped()); } @ParameterizedTest(name = "{0}") @@ -234,7 +230,6 @@ void aRejectHandlerDoesNotSeeElementsTheProducerDeclined( rejected::add); assertEquals(3, admitted); assertTrue(rejected.isEmpty(), "a declined element is the caller's own decision"); - assertEquals(0, queue.dropped()); } @ParameterizedTest(name = "{0}") @@ -277,7 +272,6 @@ void exceptionHandlerSeesTheFailureAndTheItemIsDropped( (item, failure) -> seen.add(item + ":" + failure.getMessage()))); assertEquals(Arrays.asList("a:boom"), seen, "the handler is told which item died"); - assertEquals(1, queue.dropped()); assertEquals(0, queue.size()); assertFalse(queue.process(item -> fail("nothing should be left"))); } @@ -296,7 +290,6 @@ void exceptionHandlerIsNotCalledWhenTheConsumerSucceeds( (item, failure) -> fail("handler ran for a consumer that did not throw"))); assertEquals(Arrays.asList("a"), consumed); - assertEquals(0, queue.dropped()); } @ParameterizedTest(name = "{0}") @@ -327,7 +320,6 @@ void processPropagatesAConsumerFailureWhenGivenNoStrategy( "without a strategy the queue takes no view on failure"); assertEquals("boom", thrown.getMessage()); - assertEquals(0, queue.dropped(), "a failure the caller sees is not a silent drop"); assertEquals(0, queue.size(), "the item was still consumed off the queue"); } @@ -345,7 +337,6 @@ void processReportsWorkEvenWhenTheStrategyGivesUp( }, giveUp), "the return value reports work found, not consumer success"); - assertEquals(1, queue.dropped(), "an abandoned item is counted"); } @ParameterizedTest(name = "{0}") @@ -373,7 +364,6 @@ void retriesUntilTheStrategyGivesUp(String name, IntFunction> assertEquals(2, attempts.get(), "consumed twice: original plus one retry"); assertEquals(Arrays.asList(1, 2), reported, "attempt counts survive re-admission"); - assertEquals(1, queue.dropped(), "giving up loses the item"); } @ParameterizedTest(name = "{0}") @@ -463,7 +453,7 @@ void severalProducersAndConsumersOnAnArrayRingLoseNothingAndLeakNoPlace() // 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 - // being counted as dropped, or their places never come back. + // any producer being told, or their places never come back. int capacity = 64; int producers = 4; int consumers = 4; @@ -471,6 +461,7 @@ void severalProducersAndConsumersOnAnArrayRingLoseNothingAndLeakNoPlace() 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<>(); @@ -506,6 +497,8 @@ void severalProducersAndConsumersOnAnArrayRingLoseNothingAndLeakNoPlace() for (int i = 0; i < perProducer; i++) { if (queue.tryPut("e" + i)) { admitted.incrementAndGet(); + } else { + refused.incrementAndGet(); } } }); @@ -524,8 +517,8 @@ void severalProducersAndConsumersOnAnArrayRingLoseNothingAndLeakNoPlace() } assertEquals( producers * perProducer, - admitted.get() + queue.dropped(), - "every element was either admitted or counted as dropped"); + 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. @@ -542,7 +535,6 @@ void unboundedQueueNeverRejects() { assertTrue(queue.tryPut("e" + i)); } assertEquals(1000, queue.size()); - assertEquals(0, queue.dropped()); } @org.junit.jupiter.api.Test @@ -633,7 +625,6 @@ void retryCanPartitionFailedWorkIntoSeveralItems( } assertEquals(Arrays.asList("a", "b"), consumed); - assertEquals(0, queue.dropped(), "partitioned work is not lost"); } // A reservation claims capacity on every backing; only the array backing also holds position. @@ -669,7 +660,6 @@ void abandonedReservationYieldsNothingAndGivesTheCapacityBack( // 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()); - assertEquals(0, queue.dropped(), "abandoning a place the caller claimed is not a rejection"); for (int i = 0; i < CAPACITY; i++) { assertTrue(queue.tryPut("e" + i), "the abandoned capacity is usable again"); } @@ -685,7 +675,6 @@ void reserveFailsWhenThereIsNoRoom(String name, IntFunction> f } Reservation refused = queue.tryReserve(); assertFalse(refused.granted(), "a refusal is a reservation, never null"); - assertEquals(1, queue.dropped(), "a place that could not be claimed counts like a rejection"); refused.fill("discarded"); refused.close(); @@ -750,7 +739,6 @@ void unboundedReservationAlwaysSucceeds() { } } assertEquals(1000, queue.size()); - assertEquals(0, queue.dropped()); } @ParameterizedTest(name = "{0}") @@ -833,7 +821,6 @@ void processAbandonsTheRestOfTheBatchWhenTheConsumerThrows( 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"); - assertEquals(0, queue.dropped(), "a failure the caller sees is not a drop"); } @ParameterizedTest(name = "{0}") @@ -890,7 +877,6 @@ void doesNotInvokeTwoContextProducerWhenFull( })); assertFalse(produced.get(), "a full queue must not build what it is going to reject"); - assertEquals(1, queue.dropped()); } // --- What a null means, one test per place it can appear. --- @@ -909,7 +895,6 @@ void aNullElementThrowsWithoutSpendingAPlace( String absent = null; assertThrows(NullPointerException.class, () -> queue.tryPut(absent)); assertEquals(0, queue.size()); - assertEquals(0, queue.dropped(), "a caller's bug is not a dropped element"); for (int i = 0; i < CAPACITY; i++) { assertTrue(queue.tryPut("e" + i), "the refused call must not have cost the queue a place"); } @@ -952,14 +937,12 @@ void fillingAReservationWithNullThrowsAndTheReservationStillReleases( */ @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void aProducerDecliningIsNeitherAdmittedNorDropped( - String name, IntFunction> factory) { + 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()); - assertEquals(0, queue.dropped(), "a decline is a decision, not a loss"); for (int i = 0; i < CAPACITY; i++) { assertTrue(queue.tryPut("e" + i), "every declined place must have been given back"); } @@ -995,30 +978,35 @@ void aNullContextIsCarriedThroughToTheProducer( @MethodSource("boundedQueues") void aNullRejectHandlerSaysWhatOmittingItSays( String name, IntFunction> factory) { - WorkQueue queue = factory.apply(CAPACITY); - int admitted = - queue.tryPutBatch( + WorkQueue withNull = factory.apply(CAPACITY); + int admittedWithNull = + withNull.tryPutBatch( Arrays.asList(1, 2, 3, 4, 5, 6), "x", (source, suffix) -> source + suffix, null); - assertEquals(CAPACITY, admitted); - assertEquals(2, queue.dropped()); + 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)); } - // --- One lost item, one drop, however many steps it took to lose it. --- + // --- Retry is a step, not an outcome. --- /** - * The counting bug this pins: a refused retry used to be counted where it was refused AND again - * where the strategy gave up, so one lost item moved dropped() by more than one. The stress - * test's conservation invariant could not see it, because it never retries. + * 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 aRefusedRetryIsCountedOnceWhenTheStrategyGivesUp( + void aRefusedRetryIsReportedToTheStrategyWhenTheQueueRefilled( String name, IntFunction> factory) { WorkQueue queue = factory.apply(CAPACITY); for (int i = 0; i < CAPACITY; i++) { assertTrue(queue.tryPut("e" + i)); } - assertEquals(0, queue.dropped()); AtomicBoolean retryRefused = new AtomicBoolean(); assertTrue( queue.processOrRetry( @@ -1032,37 +1020,11 @@ void aRefusedRetryIsCountedOnceWhenTheStrategyGivesUp( return false; })); assertTrue(retryRefused.get(), "the queue was full again, so the retry had to be refused"); - assertEquals(1, queue.dropped(), "one item was lost, so dropped() moves by exactly one"); } - /** - * {@code onFailure} returning true is the strategy saying it took responsibility. The queue takes - * it at its word, which is the residue of counting the outcome rather than the step. - */ @ParameterizedTest(name = "{0}") @MethodSource("boundedQueues") - void aRefusedRetryIsNotCountedWhenTheStrategyReportsItHandledIt( - String name, IntFunction> factory) { - WorkQueue queue = factory.apply(CAPACITY); - for (int i = 0; i < CAPACITY; i++) { - assertTrue(queue.tryPut("e" + i)); - } - assertTrue( - queue.processOrRetry( - item -> { - throw new IllegalStateException("consumer failed on " + item); - }, - (item, attempt, failure, retryQueue) -> { - assertTrue(queue.tryPut("filler")); - assertFalse(retryQueue.retry(item)); - return true; - })); - assertEquals(0, queue.dropped()); - } - - @ParameterizedTest(name = "{0}") - @MethodSource("boundedQueues") - void aSuccessfulRetryCountsNothing(String name, IntFunction> factory) { + void aSuccessfulRetryTakesAPlaceAgain(String name, IntFunction> factory) { WorkQueue queue = factory.apply(CAPACITY); for (int i = 0; i < CAPACITY; i++) { assertTrue(queue.tryPut("e" + i)); @@ -1078,7 +1040,6 @@ void aSuccessfulRetryCountsNothing(String name, IntFunction> f return retryQueue.retry(item); })); assertEquals(1, seenAttempt.get(), "the first failure reports attempt 1"); - assertEquals(0, queue.dropped(), "nothing was lost"); assertEquals(CAPACITY, queue.size(), "the retried item took a place again"); } @@ -1096,7 +1057,6 @@ void aBatchLongerThanOneClaimKeepsClaiming(String name, IntFunction(rejected)); assertEquals(size, queue.size()); - assertEquals(10, queue.dropped()); } @ParameterizedTest(name = "{0}") @@ -1125,7 +1084,6 @@ void aBatchClaimsNothingOnceClosed(String name, IntFunction> f List elements = Arrays.asList("a", "b", "c"); assertEquals(elements, new ArrayList<>(queue.tryPutBatch(elements))); assertEquals(0, queue.size(), "a closed queue took nothing"); - assertEquals(3, queue.dropped()); } @ParameterizedTest(name = "{0}") @@ -1206,24 +1164,24 @@ Object retrieve() { } @org.junit.jupiter.api.Test - void aRefusedFillGivesThePlaceBackAndCountsTheDrop() { + 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"); - assertEquals(1, queue.dropped(), "the lost element was not counted"); // 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 aRefusedStoreOnAPlainPutIsCountedOnce() { + void aRefusedStoreOnAPlainPutIsReportedAndLeaksNoPlace() { RefusingWorkQueue queue = new RefusingWorkQueue<>(1); assertFalse(queue.tryPut("lost")); assertEquals(0, queue.size()); - assertEquals(1, queue.dropped(), "a refusal counted more or less than once"); + // 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) { From adf58b660484490460a802a56620f66b4bfb8dc8 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 13:42:36 -0400 Subject: [PATCH 43/48] Keep size() inside the bound it reports against size() clamped the low end only, so a claimant that had spent past the capacity and not yet backed out could be reported as a size above the capacity -- a number no caller can act on, from a method whose whole purpose is to say how much of the bound is in use. The interface had no javadoc on size() at all, which is how the range went unstated in the first place. It now says what the count includes (elements held, plus places claimed by producers that have not stored yet), that it is a snapshot, and that it never leaves 0..capacity. The clamp costs one test comment its accuracy: the closed-state test explained that a missing unbias could not be caught through a size, because the offset is a multiple of 2^32 and the cast to int erased it either way. Clamped, a missing unbias reads as a full queue instead, which aClosedQueueStillReportsWhatItHolds rejects. Co-Authored-By: Claude Opus 5 --- .../java/datadog/common/queue/BaseWorkQueue.java | 6 ++++-- .../main/java/datadog/common/queue/WorkQueue.java | 11 +++++++++++ .../common/queue/WorkQueueContractTest.java | 15 +++++++++------ 3 files changed, 24 insertions(+), 8 deletions(-) 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 index 259b9ea894c..954962ab453 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -455,8 +455,10 @@ private void discardAll() { @Override public final int size() { - // Claimants at the boundary can transiently drive the count below zero before backing out. - return (int) Math.max(0, capacity - permits(state.get())); + // 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 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 index f83d70c11e9..efdb7efc096 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -326,6 +326,17 @@ boolean processOrHandle( */ 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(); /** 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 index 45f910a12ba..b67278ba6a1 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -549,12 +549,15 @@ void unboundedQueueStillCloses() { * that encoding could leak are worth pinning: applying it twice, reading a size through it, and * giving places back underneath it. * - *

These pin the behaviour; none of them currently catches its own implementation slip, and it - * is worth being straight about why. The offset is a multiple of 2^32, so {@code size()}'s cast - * back to {@code int} erases the bias whether or not the unbiasing is there; 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, which is when all three become reachable at once. + *

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") From 810fc2aef60aa5eb6211693e2aab891f5837a7d6 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 13:42:56 -0400 Subject: [PATCH 44/48] Say that a retried item may not be null A resubmission travels wrapped in its attempt count, and the wrapper is what the admission path null-checks, so a null handed to RetryQueue.retry is not turned away the way one handed to tryPut is -- it reaches the next consumer, far from the strategy that produced it. Stated with @Nonnull rather than a check: spotbugs runs on this module, so the annotation is enforced where it can be, and the javadoc says why it is the whole defence rather than leaving a reader to assume a throw. Co-Authored-By: Claude Opus 5 --- .../src/main/java/datadog/common/queue/RetryQueue.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 index d7c1815b60a..b10fba899dd 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/RetryQueue.java @@ -1,5 +1,7 @@ package datadog.common.queue; +import javax.annotation.Nonnull; + /** * The capability to resubmit work after a consumer failure. * @@ -16,9 +18,12 @@ public interface RetryQueue { * {@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(T item); + boolean retry(@Nonnull T item); /** * Resubmits several items in place of the failed item. @@ -28,8 +33,9 @@ public interface RetryQueue { * 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(T... items); + boolean retry(@Nonnull T... items); } From 360ba79a8866b7415794a1dfb9324cf22a7e9d92 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 13:43:19 -0400 Subject: [PATCH 45/48] Stop claiming the MPMC ring's poll reports a false empty Three comments said the ring's poll reports empty while another thread is midway through publishing, the mirror of what its offer does. It does not. In jctools 4.0.6 -- MpmcArrayQueue and the MpmcVarHandleArrayQueue used on 25 and later -- poll returns null on one path only: it finds the slot unpublished, re-reads the producer index, and finds it equal to the consumer index. A producer that has claimed a slot has already moved that index, so poll loops and waits for the publish instead. It can be slow where offer is wrong, which is a different problem. discardAll's bounded re-read was justified by the false claim and is still needed for a real reason, now written down: size counts places claimed by producers that have not stored yet, so the count and the backing disagree honestly, and the element on its way in is owed to the drain. That is also why the re-read has to be bounded -- the producer holding the place may be descheduled or may never store. Co-Authored-By: Claude Opus 5 --- .../java/datadog/common/queue/BaseWorkQueue.java | 14 ++++++++------ .../src/main/java/datadog/common/queue/Queues.java | 13 +++++++------ .../common/queue/WorkQueueContractTest.java | 12 +++++++----- 3 files changed, 22 insertions(+), 17 deletions(-) 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 index 954962ab453..18048780bd8 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java @@ -435,12 +435,14 @@ private Object take() { /** * 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. A backing - * may report empty with an element in it — the MPMC ring does, for the width of another thread's - * publish — and stopping there would leave elements behind holding their places, which for {@link - * #clear} and {@link #shutdown} is the difference between emptying the queue and appearing to. - * The re-read is bounded, because {@code size} also counts places claimed by producers that have - * not stored yet, and a producer still running would otherwise keep this loop here forever. + *

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; 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 44ecf3035d7..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 @@ -48,12 +48,13 @@ public static MessagePassingQueue mpscArrayQueue(int requestedCapacity) { /** * Creates a Multiple Producer, Multiple Consumer (MPMC) array-backed queue. * - *

Non-linearizable, and deliberately so: {@code offer} can refuse and {@code poll} can report - * empty while another thread is midway through publishing to the slot they are 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 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. 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 index b67278ba6a1..10480cc707d 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -449,11 +449,13 @@ void aBoundedMpmcQueueTooSmallForTheRingIsRaisedRatherThanRefused() { @org.junit.jupiter.api.Test void severalProducersAndConsumersOnAnArrayRingLoseNothingAndLeakNoPlace() throws InterruptedException { - // The MPMC ring refuses an offer, and reports empty on a poll, 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 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; From a0cd33954046d1b1914c8c9488c0936e0184560b Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 13:43:39 -0400 Subject: [PATCH 46/48] Give MaxRetries the number of retries its name promises onFailure reports the first failure as attempt 1, and MaxRetries compared attempt < maxRetries, so it allowed one fewer resubmission than it was asked for: MaxRetries(1) never retried at all. Now attempt <= maxRetries. The reading is pinned in the javadoc, since this is the kind of arithmetic that gets flipped back: the count is retries, not consumptions, so MaxRetries(3) allows four consumptions and MaxRetries(0) never resubmits. The one test asserting the old count was the only thing holding the old reading in place, and it was written alongside the class rather than against any caller's expectation. Co-Authored-By: Claude Opus 5 --- .../main/java/datadog/common/queue/MaxRetries.java | 12 ++++++++++-- .../datadog/common/queue/WorkQueueContractTest.java | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) 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 index bfe8bce7964..2e13dbefe16 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/MaxRetries.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MaxRetries.java @@ -1,6 +1,14 @@ package datadog.common.queue; -/** A {@link RetryStrategy} that resubmits an item until a fixed attempt count is reached. */ +/** + * 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; @@ -10,6 +18,6 @@ public MaxRetries(int maxRetries) { @Override public boolean onFailure(T item, int attempt, Throwable failure, RetryQueue retryQueue) { - return attempt < maxRetries && retryQueue.retry(item); + return attempt <= maxRetries && retryQueue.retry(item); } } 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 index 10480cc707d..617ec1401a4 100644 --- a/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java +++ b/utils/queue-utils/src/test/java/datadog/common/queue/WorkQueueContractTest.java @@ -383,7 +383,7 @@ void maxRetriesBoundsResubmission(String name, IntFunction> fa // drain } - assertEquals(3, attempts.get()); + assertEquals(4, attempts.get(), "three retries on top of the original consumption"); } @ParameterizedTest(name = "{0}") From 5fc2d2dd9893734997367452702e008d711b996b Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 13:43:56 -0400 Subject: [PATCH 47/48] Point at the JCTools docs instead of re-teaching them MpmcWorkQueue's class javadoc carried three paragraphs on why an MPMC array queue is usable behind this bound and not in general. A reader of this class needs one sentence of that -- the bound does not live in the ring, so a refusal can only mean not yet -- and can consult JCTools for the rest. Review feedback, and it removes the poll claim corrected in the previous commit along with it. Nothing is lost outright: the 0.24% refusal-with-room measurement and the argument about why an ordinary caller cannot use the ring this way stay in Queues.mpmcArrayQueue, where the general warning belongs, and storeRetrying keeps the frequency note beside the loop it justifies. Co-Authored-By: Claude Opus 5 --- .../datadog/common/queue/MpmcWorkQueue.java | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) 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 index ba3994a20ae..a5f72792a3a 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/MpmcWorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MpmcWorkQueue.java @@ -22,25 +22,12 @@ * MPMC queue exists only in its {@code Unsafe} form, and {@link Queues} deliberately moves off * {@code Unsafe} on Java 25 and later. * - *

Why an array queue is usable here and not in general

- * - *

The MPMC ring is not linearizable. Its {@code offer} refuses, and its {@code poll} reports - * empty, when another thread has claimed the slot they are looking at but has not yet published to - * it — on a queue that is neither full nor empty. Four producers and four consumers on a queue of - * eight produced a refusal-with-room on roughly 0.24% of offers. For an ordinary caller that is - * disqualifying, because a refusal is ambiguous: full, or not yet, with no way to tell them apart. - * - *

Here it is not ambiguous, because the bound does not live in the ring. A place is claimed - * before {@link #store} is ever called, and places outstanding never exceed capacity, and {@link - * BaseWorkQueue} gives a place back only after the element has been retrieved and the slot is - * already free. So a thread that reaches {@code store} holds a claim, a slot is free or is in the - * act of becoming free, and a refusal can only mean not yet. Retrying is therefore guaranteed to - * succeed, and the property that makes admission cheap — an authoritative counter in front of the - * structure — is the same property that makes a lying structure safe to sit behind it. - * - *

The retry is bounded anyway. If the accounting were ever wrong, an unbounded spin would turn a - * bug into a hang, and a hang is the failure mode this package has already been bitten by. Past the - * bound the element is dropped and counted, which is what an over-capacity admission does today. + *

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 { From 77144cacdf9b70037516dafd79f480ce08849d31 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 14:23:13 -0400 Subject: [PATCH 48/48] Say on the producer forms what a null return means The interface preamble documented a null producer return as a decline, but the three producer overloads said only "whether the element was admitted" -- so a caller reading the method it is about to call learned neither that declining is available nor that it is indistinguishable from a refusal in the return. Both are now stated where they are read, including the case that made it worth writing down: a drain skipping a counter that sits at zero cannot tell "nothing to send" from "no room" from this boolean, and wants the batch form, which reports how many of its source elements it admitted. Co-Authored-By: Claude Opus 5 --- .../java/datadog/common/queue/WorkQueue.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 index efdb7efc096..c3c604bcb3b 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/WorkQueue.java @@ -134,7 +134,15 @@ public interface WorkQueue { /** * Admits an element, constructing it only once a slot is reserved. * - * @return whether the element was admitted + *

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); @@ -142,7 +150,8 @@ public interface WorkQueue { /** * Admits an element derived from {@code context}, constructing it only once a slot is reserved. * - * @return whether the element was admitted + * @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); @@ -150,7 +159,8 @@ public interface WorkQueue { /** * Admits an element derived from two contexts, constructing it only once a slot is reserved. * - * @return whether the element was admitted + * @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

Hand-rolled admission failures, and what this API does about them
FailureSeen asHere
The {@code offer} return is discarded, so a drop is invisibleSeven call sites in {@code RumInjectorMetrics}; two in {@code WafMetricCollector}; - * {@code ProductChangeCollector}; {@code IntegrationsCollector}Impossible. The count lives in the queue, not at the call site, so ignoring the - * return of {@code tryPut} still leaves the loss on {@link #dropped}
The same queue is handled two ways in one class{@code WafMetricCollector} tests the return at a dozen sites and drops it at twoImpossible. Consistency is not a per-site discipline when the queue countsThe 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