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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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)