Add WorkQueue<T> with MPSC and linked-queue backings - #12313
Conversation
API surface only, no backing implementation yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This comment has been minimized.
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>
Update: two backings addedBoth are package-private and reachable only through Reserve-first is real, not emulatedWorth recording, because it decides whether the central guarantee is achievable by wrapping JCTools rather than forking it. I decompiled
Both CAS-claim the slot first, and both return early without calling the supplier at all when there's no capacity. So A consequence the ticket didn't noteThe 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 Two API defects found by the compiler
Deviations from the ticket, deliberate
On
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: 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>
Renamed:
|
| 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
— resolved by the rename.Queuecollides withjava.util.Queue- 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. RetryQueue.retry(T...)unchecked warning — unchanged. (Note it kept its name: it is the retry capability, not aWorkQueue.)Module placement— settled:utils/queue-utils, alongsideQueues.- 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>
Dropped
|
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>
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>
|
@codex review |
There was a problem hiding this comment.
💡 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".
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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()).
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) { |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
💡 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; |
There was a problem hiding this comment.
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 👍 / 👎.
| if (done) { | ||
| return; | ||
| } | ||
| requireElement(element); | ||
| done = true; |
There was a problem hiding this comment.
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 👍 / 👎.
| 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); |
There was a problem hiding this comment.
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 👍 / 👎.
| /** | ||
| * 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. |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
🤖 Datadog Autotest · Commit 77144ca · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest
| return true; | ||
| } | ||
| } | ||
| return false; |
There was a problem hiding this comment.
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
What Does This Do
Adds
datadog.common.queue.WorkQueue<T>in a newutils/queue-utilsmodule: 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 aRejectHandleroverload — the queue walks a source collection and transforms as it goes, returning how many it admitted. A producer returningnulldeclines that source element: its place goes back and nothing is counted, sointended - admittedis 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 reportsgranted() == false, nevernull.Consumption is synchronous, in the caller's frame:
process(consumer),process(limit, consumer), context-carrying variants, andprocessOrRetry/processOrHandlefor callers that want to say what a consumer failure means (RetryStrategy,RetryQueue,MaxRetries,ExceptionHandler). Plainprocesspropagates; 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: jctoolsMpscArrayQueue, and aConcurrentLinkedQueuefor callers that need multiple consumers. Both are bounded by the same permit counter, which also makessize()O(1) rather than a list walk.Tests:
WorkQueueContractTestruns the shared contract against every backing, plusMpscWorkQueueStressTest.AdmissionBenchmarkcovers 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.
WafMetricCollectorhas it fourteen times:The counter is already zeroed when the
offerfails, 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 aboutgetAndSetorofferis 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:
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
SpanSnapshotper 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:
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.refusedProducervsrefusedBuildThenOfferis ~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.closedflag 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, andclosedand capacity can no longer be read out of step. A producer can no longer see an open flag and then claim a placeclose()already revoked. The count is alongbecausecreateUnboundedMpmcQueueseeds it withInteger.MAX_VALUE, which leaves anintno headroom for the bias —close()there would have silently done nothing.AdmissionBenchmarkremains@Threads(1)andScope.Thread, so itstryPut*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).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@Groupwith@GroupThreads(1)silently produced two consumers under-Pjmh.threads=8and wedged a run for 28 minutes. The factory javadoc now states the consequence; nothing enforces it.@codex reviewpass, both narrow:size()clamped to the capacity it reports against (it could exceed it while a claimant was backing out of an overspend), andMaxRetries(n)now performsnretries rather thann - 1. A third finding — retry transiently-empty reads in the MPMC backing — did not hold: jctools'pollspins for a pending publish and returnsnullonly 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 (sizecounts places claimed by producers that have not stored yet).dropped(); counting and publishing a refusal stays the caller's, which is the divisionFlagEvaluationWriterImplalready 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'sboolean,tryPutBatch's admitted count,Reservation.granted(), theRejectHandler— 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 aRetryStrategyabandons is now lost with no report to the caller.processOrRetryreturns only whether there was an item, soRetryStrategy.onFailure'strue/false— resubmitted versus gave up — is the only signal, and nothing reads it; with a library strategy likeMaxRetriesthe caller never learns. That is the single loss in this class with no synchronous channel back, and it wants either a changedprocessOrRetryreturn or the counter back. Both are follow-ups.nulldeclines: its place goes back, and noRejectHandleris told, because nothing was lost. Thebooleanon the single-elementtryPutforms still collapses a decline and a refusal into the samefalse, 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.tryPutBatch(Collection),tryPutBatch(T...), theRejectHandleroverload,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 isclosed = 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-readingclosedafter 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.capacityelements 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.