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>
| * Wraps an item that has already failed, carrying its attempt count back into the queue. Only | ||
| * allocated on the failure path, so the common case stores the element itself. | ||
| */ | ||
| private static final class Retried<T> { |
There was a problem hiding this comment.
To Claude - I think I prefer the present tense Retry to Retried
| @Override | ||
| @SafeVarargs | ||
| public final Collection<T> tryPutBatch(T... elements) { | ||
| List<T> rejected = null; |
There was a problem hiding this comment.
For Claude - is this a location where batch claiming could work?
There would need to a limit on batch size, so it doesn't created starvation. But at least here, there's some safety since the rejects are returned to be used again.
I suppose the alternative is to registered the elements in a simple Generator, but we don't have Generator support yet.
| for (T element : elements) { | ||
| if (!tryPut(element)) { | ||
| if (rejected == null) { | ||
| rejected = new ArrayList<>(); |
There was a problem hiding this comment.
Should we estimate the reject count based on availability cap and number of elements that we're trying to submit?
| for (T element : elements) { | ||
| if (!tryPut(element)) { | ||
| if (rejected == null) { | ||
| rejected = new ArrayList<>(); |
There was a problem hiding this comment.
Same question about estimating reject count?
| } | ||
|
|
||
| @Override | ||
| public int size() { |
There was a problem hiding this comment.
For Claude, I tend to want to make methods final for both code cleanest and performance reasons. Is that possible here?
| /** | ||
| * @return the elements that were not admitted, empty if all were | ||
| */ | ||
| Collection<T> tryPut(Collection<? extends T> elements); |
There was a problem hiding this comment.
For Claude - We should probably call tryPutBatch, too
| * | ||
| * @return whether there was an item to consume | ||
| */ | ||
| boolean process(Consumer<? super T> consumer, @Strategy RetryStrategy<T> retryStrategy); |
There was a problem hiding this comment.
I think we should probably also have...
boolean process(Consumer, ExceptionHandler)
interface ExceptionHandler {
void handle(Throwable t);
}
I mostly just want that for the case where someone wants to log, etc without retrying.
- Retried becomes Retry, present tense like the rest of the names. - BaseWorkQueue's implementations are final: two backings, one body each. - tryPut(Collection) becomes tryPutBatch(Collection), matching the varargs form. - Both batch forms size the reject list from what is left rather than regrowing. - process(Consumer, ExceptionHandler) handles a failure without deciding to retry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An ExceptionHandler now takes the item as well as the throwable: the consumer that threw cannot say which one died. process(Consumer, RetryStrategy) becomes processOrRetry, and the handler form processOrHandle, so no two-argument process overloads remain to be told apart by arity. That also settles the older process(consumer, null) ambiguity. The context forms get the same treatment, including a new processOrHandle(C, BiConsumer, ExceptionHandler). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Exercises the four producer forms, the reservation pair, and the refused path, parameterized by backing so the template method's shared store() call site is measured at one receiver type and at two. That call site is the reason the number of backings loaded in a process is an admission cost and not just a dispatch cost. The code shapes underneath this were studied separately and now live on dougqh/apmlp-1799-try-t, since the question generalizes past the queue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tryReserve returned either a fresh PlaceReservation or a static REFUSED. Merging an allocation with a globally reachable reference at a phi is a shape escape analysis gives up on, so at a call site that sees both outcomes the granted reservation is allocated for real. The new reserveMixed arm measures 12 B/op that way and 0 with a single allocation site carrying the outcome in a field, on JDK 17. The condition is worth stating precisely, because the first two arms do not show it: reserveAndFill and reserveRefused each see one outcome, C2 prunes the branch that never runs, and both designs read 0. This is insurance for the caller sitting at the capacity boundary, not a saving for everyone -- but it is free insurance, and it also keeps fill and close monomorphic for callers that never see a refusal and drops the Reservation<Object> cast. Also record on store() what the shared call site costs at a third backing, since that is the point at which the template method should give way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reference was already a field; an inner class only kept it out of sight. The shape here is asking escape analysis to delete the object and promote its fields, so the field count is the subject of the design and a hidden field is a hidden part of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The transforming batch form: the queue owns the walk, claims a place before asking the producer for anything, and reports the source elements it could not ask about. Reuses BiContextualProducer verbatim rather than adding an interface -- the signature is already (element, hoisted context) -> element, which is exactly what a per-source-element transform needs, so a caller with a bound-once producer field passes the one it already has. A null return declines the source element. That is the caller's own decision rather than a loss, so it is neither returned as a reject nor counted against dropped(), and the place claimed for it goes straight back -- which is what lets a batch of mostly-declined elements still fill the queue with the few it admits. The one imprecision is documented and pinned by a test: once the queue is full, an element the producer would have declined comes back as a reject, because the claim precedes the question. Collection rather than Iterable. Admission runs while there is room and a live consumer keeps making room, so a source with no end would not terminate; the size is also what pre-sizes the rejected list, as in the other batch forms.
10ad962 to
d155316
Compare
The count is the number a caller can act on. A caller that knows how many it meant to admit gets its exact shortfall by subtraction, with its own declines excluded from both sides -- which the refused elements cannot give, because a place is claimed before the producer is asked, so a full queue cannot tell a genuine refusal from an element the producer would have declined anyway. It also stops allocating a list for a caller that only wanted the size of one.
A RejectHandler overload, so wanting the refused source elements and wanting only the count are two shapes of one method rather than a return type that serves one of them badly. A caller that only counts pays a null test; a caller that collects picks its own accumulator instead of copying out of ours. The admission-side counterpart to ExceptionHandler, and documented with the one place the line blurs: a place is claimed before the producer is asked, so a full queue hands the handler source elements the producer would have declined, and a caller resubmitting them has to apply its own rule again.
The class had four answers to the same question. An element of null claimed a place and then threw out of the backing, leaking capacity permanently, once per call, without counting a drop. The same null inside a batch did it partway through, taking the accumulated rejects with it. A producer returning null was a silent refusal counted against dropped() in the single-element forms, but a decline that counted nothing in the batch form -- so the same lambda meant two different things depending on which method it was handed to. Only the reservation's fill() stated a policy out loud. Elements are non-null: neither backing can hold one, so there is no outcome to report, and requireElement throws before a place is claimed. fill() defers to it rather than restating it. A producer returning null is always a decline, never a drop. That removes a policy rather than adding one: tryPut returning false already cannot distinguish "no room" from "declined" and the caller acts the same either way, so dropped() was the only thing that disagreed. Counting now happens where a refusal happens instead of in a wrapper that only saw a boolean and could not tell the two apart, which is what record() is replaced by. Contexts, an optional RejectHandler and a producer's return stay nullable, and WorkQueue's javadoc now says so in one paragraph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A refused retry moved dropped() by three. admit(Object) counted the refused claim, the retry lease counted the refusal again, and consume() counted a third time when the strategy reported it gave up -- three increments, one lost item. Two of those were pre-existing; the third arrived with the null-policy commit, which taught admit(Object) to count without noticing that the retry path goes through it too. MpscWorkQueueStressTest's conservation invariant could not catch any of it, because it never retries. The rule is that a refusal is counted where the outcome is decided, and only there. admit(Object) is shared with the retry path, so it counts nothing and tryPut counts its own refusal. A refused retry is a step in a decision the strategy is still making, so the lease counts nothing either: RetryStrategy already contracts to return false when it gives up, and consume() counts that. A strategy that returns true has said it took responsibility, and is believed. The three tests added here fail against the previous commit with dropped() at 3 where 1 is expected, and at 2 where 0 is expected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The javadoc said shutdown() atomically closes and clears, and explained that sequencing the two separately leaves a window a producer can land work through. The implementation is closed = true; discardAll(), which is exactly what close(); clear() does, window included -- so the method claimed to prevent the race it has. Correcting the claim rather than closing the window. Real atomicity needs the closed flag re-read after every producer returns and before its element is stored -- four sites on the admission path, one of them per batch element -- to buy a guarantee that only matters during shutdown. That is the wrong trade to make silently; if we want it, it should be its own change with its own measurement. Says what ordering the flag first does buy, and puts the remaining half of the job where it belongs: a caller that needs the queue provably empty has to quiesce its own producers, which the queue cannot do for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AdmissionBenchmark is @threads(1) and Scope.Thread, so every thread gets its own queue. Two of the costs this module is built around are invisible in that shape. Allocation is the first. One thread allocates for almost nothing -- a pointer bump in a thread-local buffer -- so a per-operation allocation lands in B/op and barely touches ns/op, which is how an allocation on a hot path gets waved through. Several threads allocating together pay buffer refills, the bandwidth to touch fresh lines, and eventually collection, which turns the allocation into a throughput number. The reservation path was measured at 0 B/op against 12 for a shared refusal singleton, at one thread, where 12 B/op is nearly free; this is where it gets priced. refusedProducer against refusedBuildThenOffer is the whole premise of the API in that form: both admit nothing, and one never builds the element it was going to throw away while the other builds it first. Contention is the second. claimPlace spends a place with one atomic decrement and gives it back with a second when there was none, so a refused admission pays two read-modify-writes on one line, at the boundary where the most threads arrive at once. refusedRaw prices it: jctools already bounds the MPSC backing through its own producer-index CAS, so a caller that never reserves is paying the counter for a bound it had for free. The linked backing has no such baseline -- there the counter is the only thing bounding an unbounded queue. The steady arm runs producers against a draining consumer, the only arm where the counter is incremented and decremented on the same line at once. Numbers are not filled in yet; the table in the class javadoc marks the arms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rride The steady arm used a JMH @group with @GroupThreads(1) for its consumer and a comment asserting "one consumer, because MPSC allows exactly one". @GroupThreads fixes the count per group, and JMH builds as many groups as the thread count allows -- so -Pjmh.threads=8, the project's documented spot-check flag, against a group of 4 produced two consumers on a single-consumer ring. The two did not fail: they spun in jctools' gap-wait and the iteration never ended, so the run burned 28 minutes and emitted nothing. The consumer is now a thread this class starts in setup, which makes the arm correct at any thread count instead of correct at one. Same finding, stated for callers on WorkQueues.createMpscQueue: Single Consumer is a requirement, not a characteristic, and a second consumer presents as a hang rather than an error. Results filled in from an 8-thread run, and they include a reading that does not flatter the API: the permit counter, not the avoided allocation, is the dominant cost at the capacity boundary -- ~960ns to refuse against ~3.4ns on jctools' own bound. Co-Authored-By: Claude Opus 5 <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>
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.
The specific cost this is aimed at is building work you then throw away. Client-side stats is the clearest case: it builds a
SpanSnapshotper eligible span, with peer-tag and additional-tag arrays, and discards it when the inbox turns out to be full — precisely the moment the process is under the most pressure. Reserve-before-build inverts that, so a full queue costs a read and nothing else.The aim is safety, and behaving better when things are already going wrong. That is what the permit counter is for: one exact bound with the same meaning over an MPSC ring and an unbounded linked queue, a drop count that moves once per lost item, and a producer that is never asked to build for a queue with no room. Those properties 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 a price for 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
Deliberately a draft, and there is no in-tree caller on this branch — the API is currently 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.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.