Skip to content

Add WorkQueue<T> with MPSC and linked-queue backings - #12313

Open
dougqh wants to merge 49 commits into
masterfrom
dougqh/apmlp-1642-queue-api
Open

Add WorkQueue<T> with MPSC and linked-queue backings#12313
dougqh wants to merge 49 commits into
masterfrom
dougqh/apmlp-1642-queue-api

Conversation

@dougqh

@dougqh dougqh commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

What Does This Do

Adds datadog.common.queue.WorkQueue<T> in a new utils/queue-utils module: a bounded handoff between producers and a consumer whose defining property is that admission claims capacity before it invokes any producer, so an element that is going to be rejected is never constructed.

Admission, in the order you should reach for it:

  • tryPut(element) — for something already built.
  • tryPut(producer), tryPut(context, producer), tryPut(c1, c2, producer) — the queue claims a place, then asks the producer to build. The context parameters exist so a producer can stay a non-capturing, bound-once field instead of a lambda allocated per call; the ladder stops at two arguments deliberately.
  • tryPutBatch(source, context, producer) and a RejectHandler overload — the queue walks a source collection and transforms as it goes, returning how many it admitted. A producer returning null declines that source element: its place goes back and nothing is counted, so intended - admitted is the caller's exact shortfall.
  • tryPutBatch(elements) / tryPutBatch(T...) — return the elements that were refused.
  • tryReserve()Reservation<T> — the escape hatch for work that will not fold into a callback. A refusal is a reservation that reports granted() == false, never null.

Consumption is synchronous, in the caller's frame: process(consumer), process(limit, consumer), context-carrying variants, and processOrRetry / processOrHandle for callers that want to say what a consumer failure means (RetryStrategy, RetryQueue, MaxRetries, ExceptionHandler). Plain process propagates; the queue never logs and takes no view on a failure it was not given one for.

Two backings behind one bound, chosen by WorkQueues.createMpscQueue / createMpmcQueue / createUnboundedMpmcQueue: jctools MpscArrayQueue, and a ConcurrentLinkedQueue for callers that need multiple consumers. Both are bounded by the same permit counter, which also makes size() O(1) rather than a list walk.

Tests: WorkQueueContractTest runs the shared contract against every backing, plus MpscWorkQueueStressTest. AdmissionBenchmark covers the reservation path.

Motivation

Queue users in the tree hand-roll the same few things directly against jctools — a capacity check, a drop counter, a drain loop — and each one answers the same questions slightly differently. But the variation is not the interesting part. The interesting part is a failure that lives in the gap between two decisions that each look fine on their own.

The seam. A telemetry collector does two things: it reads a counter destructively, and it hands the result to a bounded queue that may refuse. The counter design is sound. The queue usage is sound. The bug is the ordering — and the ordering belongs to neither half, so nothing in a review of either half catches it. WafMetricCollector has it fourteen times:

long counter = wafInputTruncatedCounter.getAndSet(i, 0);   // state destroyed
if (counter > 0) {
  if (!rawMetricsQueue.offer(new WafInputTruncated(counter, i))) {
    return;                                                 // ...and now lost for good
  }
}

The counter is already zeroed when the offer fails, so the value is gone permanently — and this fires precisely when telemetry is backing up because the agent is unreachable, i.e. when the data is worth the most. Nothing about getAndSet or offer is wrong. Their sequence is.

What the callback does about it. Because admission claims a place before it invokes the producer, the destructive read moves inside the producer, and the producer only ever runs on a place that has already been granted. You cannot destroy state you have not yet been granted somewhere to put it. The two decisions can no longer be made separately, because the API only offers them joined:

if (!queue.tryPut(i, this, (idx, self) ->
        new WafInputTruncated(self.wafInputTruncatedCounter.getAndSet(idx, 0), idx))) {
  break;   // no place was claimed, so the counter was never read and nothing was lost
}

That is the design's central claim, and it is a correctness argument rather than a performance one. It is why the contextual and batch producer forms are the shapes the API pushes you toward, and why a plain tryPut(element) — while supported — gives up the guarantee: an element built before the call is state already committed.

Two honest limits. It is available, not enforced — capturing the read outside the lambda puts the ordering back the way it was. What changes is which version is the natural one to write, which is why the recipe belongs in the primitive's own documentation rather than being left for each adopter to rediscover from the composition. And the guarantee is about ordering, not about the drain as a whole: a caller that stops on the first refusal still leaves the rest of its counters for the next cycle. That is the correct outcome — they are untouched — but it is the batch form, which reports how many of the intended elements it admitted, that makes the shortfall visible rather than implicit.

The allocation story is the same property, seen from the other side. Building work you then throw away is the benign case of the same ordering. Client-side stats is the clearest example: it builds a SpanSnapshot per eligible span, with peer-tag and additional-tag arrays, and discards it when the inbox turns out to be full — again at the moment of most pressure. Reserve-before-build means a full queue costs a read and nothing else.

Everything else the permit counter buys follows from wanting one bound that means the same thing everywhere: an exact capacity over both an MPSC ring and an unbounded linked queue, and an O(1) size(). Those are worth most under backpressure, which is exactly where a hand-rolled capacity check is least likely to have been thought through. Read that way, the counter's ~5ns over the ring's own bound is the price of the guarantee rather than an overhead to be removed — which is why the cheaper options that trade the guarantee away (per-backing admission, an approximate bound from batched thread-local permits) are not pursued here.

Additional Notes

There is no in-tree caller on this branch — the API is exercised only by its own tests and benchmark. #12339 stacks the client-side-stats adoption on top as a trial, so the surface can be reviewed against real use. Reviewing the two together is more informative than reviewing this one alone.

Known gaps, in case they are load-bearing for your read:

  • Contention at the boundary is measured, and it is no longer the dominant cost. ContendedAdmissionBenchmark (8 threads, gc profiler, JDK 25, loaded machine — directional) first priced a refused admission at ~960ns against ~3.4ns for the same rejection on jctools' own producer-index CAS: two read-modify-writes on one shared line, taken by every thread at the capacity boundary. claimPlace() now reads the count before spending from it, so a full or closed queue refuses with a load. Refusal is ~7.9ns against ~2.9ns, putting the permit counter at roughly 5ns over a bound the MPSC ring already enforced — an accepted cost for what the API does, not an open question. The decrement stays authoritative, so the bound is unchanged.
  • Reserve-before-build now wins on both axes. refusedProducer vs refusedBuildThenOffer is ~8ns / 0 B/op against ~422ns / 32 B/op. Before the read it was the awkward result — 0 B/op but slower in ns/op, because the counter cost more than the allocation it avoided. The two arms are not one variable (no counter in one, no allocation in the other); what reversed is the ordering.
  • Trust the direction more than the ratio: 960ns is too expensive for two contended RMWs on a quiet machine, so some of that baseline was this machine's other work amplifying contention. A quiet run should show a smaller multiple against a smaller before. The ~7.9ns is tight (±0.35).
  • The closed flag is gone, folded into the permit count as a large negative bias — so the check disappears from all seven admission sites rather than getting cheaper, and closed and capacity can no longer be read out of step. A producer can no longer see an open flag and then claim a place close() already revoked. The count is a long because createUnboundedMpmcQueue seeds it with Integer.MAX_VALUE, which leaves an int no headroom for the bias — close() there would have silently done nothing.
  • AdmissionBenchmark remains @Threads(1) and Scope.Thread, so its tryPut* rows are still unmeasured; only the reservation arms are filled in. Those answered their own question: building a fresh refusal measures 0 B/op where a shared refusal singleton costs 12, because the allocation-merged-with-a-static phi defeats escape analysis (JDK 17).
  • Writing that benchmark turned up an API hazard worth a reviewer's eye: createMpscQueue's "Single Consumer" is a requirement, and a second consumer neither throws nor is rejected — the two spin in the ring's gap-wait, which presents as a hang. A JMH @Group with @GroupThreads(1) silently produced two consumers under -Pjmh.threads=8 and wedged a run for 28 minutes. The factory javadoc now states the consequence; nothing enforces it.
  • Two behaviour fixes from a @codex review pass, both narrow: size() clamped to the capacity it reports against (it could exceed it while a claimant was backing out of an overspend), and MaxRetries(n) now performs n retries rather than n - 1. A third finding — retry transiently-empty reads in the MPMC backing — did not hold: jctools' poll spins for a pending publish and returns null only when the producer and consumer indices agree, so there is no false empty to ride out. Three of our own comments claimed otherwise and are corrected; discardAll's bounded re-read stays, for the reason that is actually true (size counts places claimed by producers that have not stored yet).
  • There is no drop counter, on purpose. The queue owns no dropped(); counting and publishing a refusal stays the caller's, which is the division FlagEvaluationWriterImpl already chose by hand. It was removed rather than shipped because ten of its eleven increment sites were redundant with a return the caller already gets — tryPut's boolean, tryPutBatch's admitted count, Reservation.granted(), the RejectHandler — and nothing in the tree read it, so it was a safety net for a caller who ignores returns rather than new information. It can come back in its own PR if an adopter needs it. The one thing that went with it and should not have: an item a RetryStrategy abandons is now lost with no report to the caller. processOrRetry returns only whether there was an item, so RetryStrategy.onFailure's true/false — resubmitted versus gave up — is the only signal, and nothing reads it; with a library strategy like MaxRetries the caller never learns. That is the single loss in this class with no synchronous channel back, and it wants either a changed processOrRetry return or the counter back. Both are follow-ups.
  • A producer that returns null declines: its place goes back, and no RejectHandler is told, because nothing was lost. The boolean on the single-element tryPut forms still collapses a decline and a refusal into the same false, so a drain loop that skips zero-valued counters cannot tell "nothing to send" from "no room" without the batch form. The javadoc gap that went with it is fixed — all three producer overloads now say so at the point of use — but whether that return should distinguish the two is a design question worth a reviewer's opinion.
  • Unexercised by any caller anywhere: tryPutBatch(Collection), tryPutBatch(T...), the RejectHandler overload, tryReserve / Reservation, RetryStrategy / RetryQueue / MaxRetries, processOrRetry / processOrHandle. That is a lot of surface per adopter and a reasonable thing to push back on.
  • shutdown() is not atomic, and its javadoc now says so rather than claiming otherwise. It is closed = true; discardAll(); — a producer already past the closed check, or an in-flight retry lease, can still store an element after the discard, and that element then sits in a queue nothing will drain. Ordering the flag first bounds the survivors to those already in flight rather than eliminating them. Making it genuinely atomic means re-reading closed after every producer returns and before its element is stored — four admission sites, a per-admission cost, and a behaviour change that deserves its own PR and its own measurement. Correcting the doc is this PR; the guard is not.
  • The permit counter's bound is exact — the queue never holds more than capacity elements and open reservations — but who gets turned away is approximate at the boundary, where claimants racing can back out together and refuse an admission while the queue is a place or two short of full.

API surface only, no backing implementation yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dougqh dougqh added type: feature Enhancements and improvements comp: core Tracer core tag: performance Performance related changes tag: no release notes Changes to exclude from release notes tag: ai generated Largely based on code generated by an AI or LLM labels Aug 27, 2026
@datadog-prod-us1-5

This comment has been minimized.

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<? super T> cannot typecheck, since a strategy over a
supertype would need a RetryQueue the queue cannot satisfy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dougqh dougqh changed the title Add Queue<T> admission and consumption API Add Queue<T> with MPSC and linked-queue backings Aug 27, 2026
@dougqh

dougqh commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Update: two backings added

Both are package-private and reachable only through Queues factories — mpscQueue(capacity), mpmcQueue(capacity), unboundedMpmcQueue(). Shared admission/lifecycle/retry logic is in BaseQueue. 28 tests, all passing.

Reserve-first is real, not emulated

Worth recording, because it decides whether the central guarantee is achievable by wrapping JCTools rather than forking it. I decompiled fill(Supplier, int) in 4.0.6:

  • MpscArrayQueue (Unsafe, Java 8 path) — casProducerIndex at bytecode 141, Supplier.get() at 180.
  • MpscVarHandleArrayQueue (Java 25+ path, what Queues selects) — casProducerIndex at 128, Supplier.get() at 167.

Both CAS-claim the slot first, and both return early without calling the supplier at all when there's no capacity. So fill(supplier, 1) == 1 is exact reserve-first admission, on both paths. The stress test asserts this directly: 8 threads × 20k producing admissions against a permanently full queue invoke the producer zero times.

A consequence the ticket didn't note

The claim is published (producer index advanced) before the element is stored. A consumer that reaches that slot waits for the element to appear. So reserve-first converts "producer allocates" into "producer allocates while holding a slot the consumer may be blocked on" — a slow producer now stalls the consumer, where before it only stalled itself. Fine for SpanSnapshot; not fine for anything that blocks or does I/O in produce(). I documented this on Queue, but it's a contract the use-case cards should be checked against — OkHttpSink (APMLP-1652) builds a Request including a full buffer copy inside what would become the producer, which is the largest candidate.

Two API defects found by the compiler

  1. RetryStrategy<? super T> doesn't typecheck. A strategy over a supertype S would receive a RetryQueue<S>, and the queue can't satisfy that — it only accepts T. Changed process to take an invariant RetryStrategy<T>. The variance in the ticket's converged signature isn't merely too permissive, it's uninhabitable.
  2. The overload ambiguity is real, not theoretical. process(consumer, null) doesn't compile — ambiguous between process(Consumer, RetryStrategy) and process(C, BiConsumer). Currently worked around with a cast at the internal call site, but every caller passing a literal null hits it too. This strengthens the case for renaming the context-taking forms.

Deviations from the ticket, deliberate

  • The retry "lease" is not honoured. The ticket says a single-item retry "cannot fail on capacity" because the consumer still owns the slot. poll() releases the slot before the consumer runs, so there is no slot left to reuse — a retry is an ordinary re-admission and can be rejected if producers refilled the queue meanwhile (counted as a drop). Honouring the ticket would need a claim-based consumer, i.e. forking the backing rather than wrapping it, or reserving a hidden retry slot. Flagging rather than quietly weakening it.
  • Batch admission is element-wise, not one atomic reservation. tryPutBatch / tryPut(Collection) / RetryQueue.retry(T...) can therefore partially admit. fill(supplier, n) would make this atomic for the MPSC backing; left out of the draft.
  • shutdown() is not atomic — it sets closed, then discards. A producer already past the closed check can still land an element. Narrowing the window needs backing-level support.
  • put(BatchProducer) is lossless by construction, which I think is the intended reading: an element is pulled only once a slot is claimed, so whatever doesn't fit is still held by the producer. It stops early rather than blocking, and the caller checks hasNext(). No drops are counted for stopping at capacity.

On LinkedQueue

It keeps the per-element node, so it doesn't deliver the allocation win — it exists so multi-consumer or as-yet-unbounded call sites can adopt the interface first and be re-backed later. Its size counter is what makes the bound enforceable and size() O(1), which is precisely what DependencyResolverQueue (APMLP-1654) wants in place of a hand-rolled cap plus an O(n) ConcurrentLinkedQueue.size() walk on every admission.

Still open from the original description

The static-routine bypass for hot call sites — and with it the interface-vs-final-class dispatch question — is still not addressed. With two backings live behind Queue<T>, admission call sites that see both are now genuinely bimorphic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dd-octo-sts

dd-octo-sts Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 13.90 s 13.95 s [-1.5%; +0.7%] (no difference)
startup:insecure-bank:tracing:Agent 12.95 s 12.97 s [-0.9%; +0.6%] (no difference)
startup:petclinic:appsec:Agent 16.91 s 16.93 s [-1.0%; +0.7%] (no difference)
startup:petclinic:iast:Agent 16.86 s 16.99 s [-1.5%; -0.1%] (maybe better)
startup:petclinic:profiling:Agent 16.04 s 16.82 s [-10.1%; +0.8%] (unstable)
startup:petclinic:sca:Agent 16.88 s 16.71 s [+0.2%; +2.0%] (maybe worse)
startup:petclinic:tracing:Agent 15.66 s 16.00 s [-6.2%; +1.9%] (no difference)

Commit: 663192be · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

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) <noreply@anthropic.com>
@dougqh dougqh changed the title Add Queue<T> with MPSC and linked-queue backings Add WorkQueue<T> with MPSC and linked-queue backings Aug 27, 2026
@dougqh

dougqh commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Renamed: Queue<T>WorkQueue<T>, factories split out

Set apart from the raw JCTools factory, and the java.util.Queue collision (open question 1) is gone — no caller has to qualify an import now.

Queue            -> WorkQueue          (+ WorkQueues factory)
BaseQueue        -> BaseWorkQueue
MpscBoundedQueue -> MpscWorkQueue
LinkedQueue      -> LinkedWorkQueue

The two factory surfaces are now separate classes, because they answer different questions for the caller:

returns caller's job
WorkQueues.createMpscQueue(n) WorkQueue<T> hand work over; backing is hidden and re-backable
Queues.mpscArrayQueue(n) MessagePassingQueue<T> drive the raw queue yourself

Queues keeps only the raw factories and is otherwise untouched, so its eleven existing callers — which include OkHttpSink and ClientStatsAggregator, two of the ticket's own use cases — are unaffected until they migrate deliberately.

Usage now reads:

WorkQueue<SpanSnapshot> inbox = WorkQueues.createMpscQueue(1024);
inbox.tryPut(ctx, SNAPSHOT);
inbox.process(this::publish, new MaxRetries<>(3));

28 tests still green.

Open questions, updated

  1. Queue collides with java.util.Queue — resolved by the rename.
  2. Overload ambiguity still stands, and is still real rather than theoretical: process(consumer, null) does not compile. Worth deciding whether the context-taking forms get distinct names.
  3. RetryQueue.retry(T...) unchecked warning — unchanged. (Note it kept its name: it is the retry capability, not a WorkQueue.)
  4. Module placement — settled: utils/queue-utils, alongside Queues.
  5. Static-routine bypass / dispatch — still open, and now the sharper question of the two, since two backings sit behind WorkQueue<T>.

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) <noreply@anthropic.com>
@dougqh

dougqh commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Dropped BatchProducer and put() — deferred to the SCA follow-on

Removed from this PR. The reasoning, so the follow-on doesn't relitigate it:

Why it goes now. Across all five use cases on APMLP-1642 — client-side stats, PendingTrace, OkHttpSink, health metrics, DependencyResolverQueue — every admission is a single element. Batch admission had no caller, so it was a public type, a method, and a lossless-admission contract that every future backing would have to honour, carried on spec.

Why it comes back with SCA. SCA's partition-on-failure (the #11977 Reachability case) is the genuine caller. Landing it there means its real access pattern picks the shape, instead of us guessing between pull, push, and reservation.

The name was wrong too, which is what started this. BatchProducer sat in a family with Producer.produce() and ContextualProducer.produce(ctx) but had hasNext()/next() — so it read as "produces batches" while actually being pulled one element at a time, advertising the opposite of the mechanism. It was also structurally java.util.Iterator<T> with no remove(), which has had a throwing default since Java 8.

Agreed shape for when it returns

Not a callback taking the WorkQueue itself. Handing out the full interface exposes close(), shutdown(), clear() and process() to arbitrary caller code, and it lets the filler stash the reference past the call. It also inverts loop ownership: the filler learns about capacity only from tryPut returning false, by which point the eager tryPut(T) form has already built the element — reintroducing build-then-drop inside the one method meant to prevent it.

Instead, a scoped admission-only capability in the manner of RetryQueue — obtainable only inside the call, dead on return:

interface Admission<T> {                 // no lifecycle, no consumption
  boolean tryPut(T element);
  <C> boolean tryPut(C context, ContextualProducer<? super C, ? extends T> producer);
}

Note this is close to the reserve(int) / Reservation<T> that the ticket deliberately kept private — "only genuinely needed when caller-owned work must happen between claiming capacity and filling it." SCA bisection may be exactly that case, in which case the follow-on is really the argument for making a bounded, scoped form of reservation public. Worth deciding there with the use case in hand.

24 tests, all passing. tryPutBatch and tryPut(Collection) are still present and also have no caller today; happy to trim those too, though they cost no new type.

dougqh and others added 12 commits August 26, 2026 22:02
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Comment thread utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java Outdated
dougqh and others added 10 commits August 28, 2026 22:15
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
A batch used to claim one place per element, paying an atomic add per
element at the boundary where the most threads are arriving. Claiming
for a run of elements at once halves that, but a claim for a whole batch
is two new ways to refuse: all-or-nothing, where one short place turns
away a batch that mostly fit, and blast radius, where the dip the claim
takes in the shared count makes every concurrent single admission see
zero.

The first goes away by construction. The claim asks for the smaller of
what it wants and what the count already says is there, off the same
relaxed read a single claim does, and refunds exactly the deficit -- so
it grants what was available and can never refuse a batch that had room.
The second is what the cap bounds, at a size chosen for how many
neighbours one batcher may make refuse rather than for fairness.

The loop stops only when a claim grants nothing. A short grant is not
evidence the queue is full: claimants back out, and a declined producer
element refunds its place while still spending the run's claim.

Uncontended this is about 2ns per element slower -- the bookkeeping
costs more than the atomic it removes. Contended it is 1.3x to 3.3x
faster. Both tables are in the benchmarks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comparison exists whether or not we run it, so run it. The baselines
are the two guards already in the tree -- WafMetricCollector's
ArrayBlockingQueue(1024) and RumInjectorMetrics' LinkedBlockingQueue(1024),
both of which build the element and then find out there was no room --
plus the hand-rolled counter in front of a ConcurrentLinkedQueue, and the
raw ring as the floor.

Admitting, the raw ring wins and nothing built on it will not; the API
costs 12ns more and still comes in under both incumbents. Refusing, it
is an order of magnitude apart, because it is the only one that has not
already allocated by the time it asks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The linked queue was never the right structure for a bounded buffer; it
was the only one whose refusals we could believe. JCTools' MPMC ring is
not linearizable -- offer refuses, and poll reports empty, while another
thread is midway through publishing to the slot in question, on a queue
that is neither full nor empty. Measured at 0.24% of offers with four
producers and four consumers on a ring of eight. For an ordinary caller
that is disqualifying, because a refusal is ambiguous.

Here it is not, because the bound does not live in the ring. A place is
claimed before store is called, places outstanding never exceed
capacity, and a place comes back only after the element has been
retrieved. A thread that reaches store therefore holds a claim, a slot
exists, and a refusal can only mean not yet -- so it retries. The same
property that makes admission cheap, an authoritative counter in front
of the structure, is what makes a lying structure safe behind it. The
retry is bounded anyway: a wrong invariant should degrade to a counted
drop, not a hang.

Bounded and unbounded share one class rather than arriving as two types.
store and retrieve are the sites every admission and drain funnels
through, and a third implementation of them makes those megamorphic for
callers that only ever touch one backing. A branch on a final field is
much the cheaper way to hold two structures.

What this buys is allocation, not latency: the per-element node is gone,
and time is at parity. The 8ns the two structures differ by in isolation
does not survive the admission machinery on top -- BackingOverhead
prices the structures, BatchAdmission shows the delta vanishing. The
benchmark tables that named a LINKED arm have been re-measured, since
the arm they described no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
store and retrieve are one call site each, shared by every backing in
the process, so their receiver profile is global -- a queue used nowhere
near yours writes into it. The comment on store said a third
implementation makes that site megamorphic and charges 24 bytes and
three times the time per call, to callers that only ever touch one
backing. Half of that is true.

PrintInlining confirms the mechanism exactly: at three types C2 reports
both as "failed to inline: virtual call", where at one and two it
inlines them hot, on JDK 17 and JDK 25 alike. What it does not confirm
is the price. One to two nanoseconds on a twenty-one nanosecond
admit-and-drain, and no allocation difference at all -- because the
operation is two uncontended atomics and a ring compare-and-set, and an
out-of-line call is little against memory ordering. Contention widens
the atomics and narrows this further. Batching does not help the
argument either: the drain still returns a place per element.

The one path that does care is admitting through a reservation, at
roughly 30%, where the store sits at the end of a chain of
optimizations that has to survive a call that stopped folding away. It
is still scalar-replaced, so it is time and not garbage.

So AdmissionBenchmark grows a THREE arm and a third backing to carry
it, kept in the benchmark source set because shipping one would answer
a different question. Its table had six rows of literal "?", which a
reader has no way to tell from a measurement; those are filled in, and
every arm now drives the same traffic through the shared sites so only
the number of types in it varies -- the ONE arm previously skipped that
loop entirely and so differed in more than its type count.

Also drops a javadoc block that described the shared refusal singleton,
a design this branch rejected. It had come loose from the field it
described and settled on the drop counter, where it was simply wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PlaceReservation.fill discarded store's return. That was safe while the
only backing was MpscWorkQueue, whose store cannot fail once a place is
in hand; MpmcWorkQueue's bounded retry made false reachable, and at that
point the element vanished, the drop went uncounted, and the permit
leaked for the life of the queue.

Route fill through storeOrRelease, the same tail every other admission
takes. The regression test needs a backing that refuses on demand, which
no shipped one does, so it carries its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lease() builds a RetryQueue per failure, and the javadoc claimed it
never escapes -- a claim about well-behaved strategies, not about what
the compiler can prove. RetryLeaseBenchmark asks the compiler.

It costs nothing in every shape but one: a strategy that is not a
constant, at a call site that has gone megamorphic, pays 24 bytes per
failure. A static final strategy pays nothing at any number of loaded
types, because a constant receiver devirtualizes by resolution and the
polluted profile is never consulted -- which is why the first cut of
this benchmark could not find the case at all.

The comment on lease() now says which of those a reader is in, and why
the cheaper-looking field-held lease is not available: one lease per
queue is one mutable attempt number, and createMpmcQueue means several
consumers can be failing at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One strategy object, four ways of reaching it, at one, two and four
loaded implementations. Three cost nothing at any type count: a static
final field, an inline lambda, and -- the one worth knowing -- an inline
capturing lambda, whose allocation site tells C2 the exact class even
though nothing folds to a constant. The fourth, an ordinary instance
field, is the only shape that pays, and it pays exactly where the
receiver profile stops being able to answer.

So the rule is not "hoist the strategy into a field". A plain final
instance field is the failing case, because HotSpot does not trust
non-static finals and a reader cannot tell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The interface-typed field is not "fields lose". A field declared at a
concrete final class reads 0 B/op at four loaded implementations, from a
field left deliberately non-final: the declared type gives C2 the exact
klass and the value never has to be trusted.

That is the only route open to a per-instance strategy, since folding
this.strategy would need the holder to be a constant as well, and it is
the one route a lambda cannot take, its class being unnameable. Shared
policy gets static final or an inline lambda; per-instance policy gets a
named final class. The failing arm is the first shape used for the
second situation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dougqh

dougqh commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 95005d0740

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java Outdated
Comment thread utils/queue-utils/src/main/java/datadog/common/queue/BaseWorkQueue.java Outdated
Comment thread utils/queue-utils/src/main/java/datadog/common/queue/MaxRetries.java Outdated
The motivation was "callers duplicate a capacity check and a drop
counter", which is the weak form. The duplication is not the cost; it is
where the stability gaps come from.

So the javadoc now carries the list of admission failures actually
present in this tree, each against what this API does about it -- and
says plainly which become impossible, which becomes merely unlikely, and
which is only made explicit. Three of them are in one class.

FlagEvaluationWriterImpl is cited as the counter-example, because it
gets all of this right by hand and that is the point: one team derived
it and wrote it down, which is the work this module pays for once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
*
* <h2>Why an array queue is usable here and not in general</h2>
*
* <p>The MPMC ring is not linearizable. Its {@code offer} refuses, and its {@code poll} reports

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To Claude - I'm not sure if we need that level of detail in the Javadoc. I think we can just indicate that this is backed by JCTools MPMC ring and then if someone is sufficiently interested they can consult the JCTools documentation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 5fc2d2d — the heading and its three paragraphs are now one, saying only what a reader of this class needs: the ring can refuse a queue that is not full, the bound does not live in the ring so a refusal can only mean not yet, the retry is bounded so a wrong accounting cannot become a hang, and a pointer to Queues.mpmcArrayQueue for the general argument.

Nothing was lost outright: the 0.24% refusal-with-room measurement and the reason an ordinary caller cannot use the ring this way stay in Queues.mpmcArrayQueue, where the general warning belongs, and storeRetrying keeps its one-line frequency note beside the loop it justifies.

It also removed a wrong sentence for free — the trimmed text claimed the ring's poll reports a false empty, which it does not (see the Codex thread on retrieve()).

dougqh and others added 6 commits September 1, 2026 13:42
WorkQueue owned a dropped() counter over every loss: refused admissions,
elements a backing would not take, items a retry strategy gave up on. Ten
of its eleven increment sites duplicated something the caller was already
told synchronously -- tryPut's boolean, tryPutBatch's admitted count,
Reservation.granted(), the RejectHandler -- and nothing in the tree read
the counter, so it was a safety net for a caller ignoring returns rather
than information the caller could not otherwise get.

The eleventh site is the exception and is left documented rather than
fixed: an item a RetryStrategy abandons is now lost with no report to the
caller, because processOrRetry returns only whether there was an item. It
wants either a changed return there or the counter back, in its own PR.

The stress tests now have producers count their own refusals, so the
conservation invariants check admitted + refused against what callers were
actually told -- the return value the API expects them to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
size() clamped the low end only, so a claimant that had spent past the
capacity and not yet backed out could be reported as a size above the
capacity -- a number no caller can act on, from a method whose whole
purpose is to say how much of the bound is in use.

The interface had no javadoc on size() at all, which is how the range
went unstated in the first place. It now says what the count includes
(elements held, plus places claimed by producers that have not stored
yet), that it is a snapshot, and that it never leaves 0..capacity.

The clamp costs one test comment its accuracy: the closed-state test
explained that a missing unbias could not be caught through a size,
because the offset is a multiple of 2^32 and the cast to int erased it
either way. Clamped, a missing unbias reads as a full queue instead,
which aClosedQueueStillReportsWhatItHolds rejects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A resubmission travels wrapped in its attempt count, and the wrapper is
what the admission path null-checks, so a null handed to RetryQueue.retry
is not turned away the way one handed to tryPut is -- it reaches the next
consumer, far from the strategy that produced it.

Stated with @nonnull rather than a check: spotbugs runs on this module,
so the annotation is enforced where it can be, and the javadoc says why
it is the whole defence rather than leaving a reader to assume a throw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three comments said the ring's poll reports empty while another thread is
midway through publishing, the mirror of what its offer does. It does
not. In jctools 4.0.6 -- MpmcArrayQueue and the MpmcVarHandleArrayQueue
used on 25 and later -- poll returns null on one path only: it finds the
slot unpublished, re-reads the producer index, and finds it equal to the
consumer index. A producer that has claimed a slot has already moved that
index, so poll loops and waits for the publish instead. It can be slow
where offer is wrong, which is a different problem.

discardAll's bounded re-read was justified by the false claim and is
still needed for a real reason, now written down: size counts places
claimed by producers that have not stored yet, so the count and the
backing disagree honestly, and the element on its way in is owed to the
drain. That is also why the re-read has to be bounded -- the producer
holding the place may be descheduled or may never store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
onFailure reports the first failure as attempt 1, and MaxRetries compared
attempt < maxRetries, so it allowed one fewer resubmission than it was
asked for: MaxRetries(1) never retried at all. Now attempt <= maxRetries.

The reading is pinned in the javadoc, since this is the kind of
arithmetic that gets flipped back: the count is retries, not
consumptions, so MaxRetries(3) allows four consumptions and MaxRetries(0)
never resubmits. The one test asserting the old count was the only thing
holding the old reading in place, and it was written alongside the class
rather than against any caller's expectation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MpmcWorkQueue's class javadoc carried three paragraphs on why an MPMC
array queue is usable behind this bound and not in general. A reader of
this class needs one sentence of that -- the bound does not live in the
ring, so a refusal can only mean not yet -- and can consult JCTools for
the rest. Review feedback, and it removes the poll claim corrected in the
previous commit along with it.

Nothing is lost outright: the 0.24% refusal-with-room measurement and the
argument about why an ordinary caller cannot use the ring this way stay
in Queues.mpmcArrayQueue, where the general warning belongs, and
storeRetrying keeps the frequency note beside the loop it justifies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* @param requestedCapacity the bound. Will be rounded to the next power of two, and raised to two
* if it is less than that.
*/
public static <E> WorkQueue<E> createMpmcQueue(int requestedCapacity) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm debating calling this createBoundedMpmcQueue. I'm curious to hear what others think.

The interface preamble documented a null producer return as a decline,
but the three producer overloads said only "whether the element was
admitted" -- so a caller reading the method it is about to call learned
neither that declining is available nor that it is indistinguishable from
a refusal in the return.

Both are now stated where they are read, including the case that made it
worth writing down: a drain skipping a counter that sits at zero cannot
tell "nothing to send" from "no room" from this boolean, and wants the
batch form, which reports how many of its source elements it admitted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dougqh
dougqh marked this pull request as ready for review September 1, 2026 18:23
@dougqh
dougqh requested review from a team as code owners September 1, 2026 18:23
@dougqh
dougqh requested review from AlexeyKuznetsov-DD and removed request for a team September 1, 2026 18:23

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 77144cacdf

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

return true;
}
}
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve claimed elements while the MPMC publisher is stalled

When another ring producer is descheduled after claiming its slot but before publishing it, all 64 offers can complete before that thread runs again, causing this method to return false even though the permit counter already reserved capacity. Producer-based admission has already invoked its producer at this point, so destructively collected state can be lost despite the API's reserve-before-build guarantee; Reservation.fill is worse because it ignores the failure and silently discards an element its contract says cannot be rejected. An already-claimed MPMC store must not be abandoned merely because this short retry budget expires.

Useful? React with 👍 / 👎.

Comment on lines +405 to +409
if (done) {
return;
}
requireElement(element);
done = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make reservation completion safe across producer threads

If a granted reservation is handed to another producer thread, concurrent fill/fill or fill/close calls can both observe done == false because this flag is neither atomic nor synchronized. Two fills can then publish two elements against one permit, while a racing close can return the permit and still allow an element to be stored, corrupting the capacity accounting and eventually causing over-admission or unrelated store failures. The public Reservation contract does not require thread confinement, so completion must be atomic or that restriction must be made explicit and enforced.

Useful? React with 👍 / 👎.

Comment on lines +759 to +766
if (retryStrategy == null && exceptionHandler == null) {
// No strategy means no opinion about failure: the throw travels out to the caller's own
// frame, where its existing error handling already lives. Swallowing it here would make a
// queue the arbiter of an error policy nobody handed it.
if (consumer != null) {
consumer.accept(item);
} else {
biConsumer.accept(context, item);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject null callbacks before removing an item

When a caller passes a null consumer or failure policy to processOrRetry/processOrHandle, the null is reused as an internal dispatch sentinel rather than rejected as required by the public contract. A null policy makes a throwing consumer behave like plain process and propagate its original exception, while a null consumer produces an internal NullPointerException that a non-null retry strategy or handler can swallow or repeatedly retry; in both cases the item has already been removed. Validate the public callbacks before take() instead of letting their nullness select an internal processing mode.

Useful? React with 👍 / 👎.

Comment on lines +45 to +47
/**
* 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop claiming that refused reservations count their drop

A refused reservation does not increment any counter: tryReserve() only attempts claimPlace() and returns an inert reservation when that fails. This statement therefore tells callers that silently filling a refused reservation has already recorded the loss, even though the queue exposes no such record and the broader WorkQueue documentation says refusal accounting remains the caller's responsibility. A caller relying on this contract can lose work without either observing granted() or maintaining its own drop metric, so remove the counting claim or implement the promised accounting.

Useful? React with 👍 / 👎.

@datadog-prod-us1-5 datadog-prod-us1-5 Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Datadog Autotest: FAIL

The bounded MPMC queue can drop an element after it claims capacity and calls its producer. The 64-retry limit can end before a delayed producer publishes its ring slot.

Open Bits AI session

🤖 Datadog Autotest · Commit 77144ca · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest

return true;
}
}
return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Bounded MPMC retries can drop a produced element

The queue can lose work or destructively read state without a report to the caller.

Assertion details
  • Input: A bounded MPMC queue has a delayed producer and concurrent activity that causes 64 transient offer failures.
  • Expected: After the queue claims capacity and calls the producer, it must store each non-null element.
  • Actual: The queue returns false after 64 failed offers. It releases the permit and drops the produced element.

Was this helpful? React 👍 or 👎
🤖 Datadog Autotest · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest · Open Bits AI session

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: core Tracer core tag: ai generated Largely based on code generated by an AI or LLM tag: no release notes Changes to exclude from release notes tag: performance Performance related changes type: feature Enhancements and improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant