Repository navigation
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>
bric3
left a comment
There was a problem hiding this comment.
This is only a first pass review.
I used these definitions:
An item is the value enqueued. A producer is a callback that creates or obtains that value; it may also read and reset a counter. A permit represents capacity for one item. Claiming a permit reduces the capacity available to other producers before the item is enqueued.
In these diagrams, storage means enqueueing an item in the underlying queue. An item described as stored has been enqueued. Processing means invoking the consumer with an item removed from the queue.
I asked Codex to draw these diagrams to help me understand the implementation.
The colors mean:
- Blue: public operations.
- Amber: permits.
- Purple: enqueue operations.
- Green: processing and resubmission.
- Red: refusal or abandonment.
Type hierarchy
classDiagram
direction TB
class WorkQueues
class WorkQueue
class BaseWorkQueue
class MpscWorkQueue
class MpmcWorkQueue
class Reservation
class RetryStrategy
class RetryQueue
class MaxRetries
WorkQueues ..> WorkQueue : creates
WorkQueue <|.. BaseWorkQueue
BaseWorkQueue <|-- MpscWorkQueue
BaseWorkQueue <|-- MpmcWorkQueue
WorkQueue ..> Reservation : returns
WorkQueue ..> RetryStrategy : accepts
RetryStrategy ..> RetryQueue : receives
RetryStrategy <|.. MaxRetries
style WorkQueues fill:#DBEAFE,stroke:#2563EB,color:#111827,font-family:monospace
style WorkQueue fill:#DBEAFE,stroke:#2563EB,color:#111827,font-family:monospace
style BaseWorkQueue fill:#FEF3C7,stroke:#D97706,color:#111827,font-family:monospace
style Reservation fill:#FEF3C7,stroke:#D97706,color:#111827,font-family:monospace
style MpscWorkQueue fill:#EDE9FE,stroke:#7C3AED,color:#111827,font-family:monospace
style MpmcWorkQueue fill:#EDE9FE,stroke:#7C3AED,color:#111827,font-family:monospace
style RetryStrategy fill:#DCFCE7,stroke:#16A34A,color:#111827,font-family:monospace
style RetryQueue fill:#DCFCE7,stroke:#16A34A,color:#111827,font-family:monospace
style MaxRetries fill:#DCFCE7,stroke:#16A34A,color:#111827,font-family:monospace
BaseWorkQueue manages permits, calls producers, and invokes consumers. Its subclasses implement store() and retrieve() using these underlying queues:
| Implementation | Producer threads | Threads removing items | Underlying queue |
|---|---|---|---|
| Bounded MPSC | Multiple | One | JCTools circular array |
| Bounded MPMC | Multiple | Multiple | JCTools circular array |
| Unbounded MPMC | Multiple | Multiple | ConcurrentLinkedQueue |
The unbounded version starts with Integer.MAX_VALUE permits.
Accepting an item
When a permit is obtained, tryPut(producer) calls the producer immediately. tryReserve() returns a reservation that the caller can later fill or close.
Dotted arrows represent later calls made by the caller.
flowchart TD
subgraph ProducerPath["tryPut(producer)"]
Put["tryPut(producer)"] --> ClaimPut["claimPlace()"]
ClaimPut --> GrantedPut{"Permit obtained?"}
GrantedPut -->|No| Refused["Return false; producer not called"]
GrantedPut -->|Yes| Produce["producer.produce()"]
Produce -->|throws| Throw["Return permit; propagate exception"]
end
subgraph ReservationPath["tryReserve() and reservation methods"]
Reserve["tryReserve()"] --> ClaimReserve["claimPlace()"]
ClaimReserve --> Reservation["Return reservation; done = !granted"]
Reservation -.->|later caller invocation| Fill["fill(nonNullItem)"]
Reservation -.->|later caller invocation| Close["close()"]
Fill --> FillDone{"done?"}
FillDone -->|Yes| FillReturn["Return without storing"]
FillDone -->|No| Complete["Set done = true"]
Close --> CloseDone{"done?"}
CloseDone -->|Yes| CloseReturn["Return without changing permits"]
CloseDone -->|No| Cancel["Set done = true; return permit"]
end
subgraph StoragePath["Shared storeOrRelease(item) implementation"]
Produce -->|returns item or null| NonNull{"Item non-null?"}
Complete --> NonNull
NonNull -->|No| ReturnPermit["Return permit; result is false"]
NonNull -->|Yes| Store["store(item)"]
Store -->|accepted| KeepPermit["Keep permit claimed; result is true"]
Store -->|refused| ReturnPermit
end
classDef api fill:#DBEAFE,stroke:#2563EB,color:#111827,font-family:monospace
classDef permit fill:#FEF3C7,stroke:#D97706,color:#111827,font-family:monospace
classDef storage fill:#EDE9FE,stroke:#7C3AED,color:#111827,font-family:monospace
classDef refusal fill:#FEE2E2,stroke:#DC2626,color:#111827,font-family:monospace
class Put,Reserve,Produce,Fill,Close api
class ClaimPut,ClaimReserve,GrantedPut,Reservation,FillDone,CloseDone,Complete,Cancel,NonNull,KeepPermit permit
class Store storage
class Refused,Throw,FillReturn,CloseReturn,ReturnPermit refusal
Retries
The implementation retries two operations:
storeRetrying()repeatsoffer(item)when the underlying queue refuses the item.RetryStrategy.onFailure()can resubmit an item after its consumer throws.
They can occur in this order:
- The queue enqueues an item, repeating
offer()if needed. - A caller removes the item and invokes its consumer.
- If the consumer throws, the retry strategy may resubmit the item.
- Resubmission obtains a new permit and calls the same enqueue implementation, which may repeat
offer()again.
The counters measure different events. storeRetrying() counts additional calls to offer(). RetryStrategy.onFailure() receives the number of consumer failures for that item, including the current failure.
Enqueue retry
An enqueue retry calls the underlying queue's
offer(item)again with the same item and the permit already claimed.
flowchart TD
Store["store(item)"] --> Implementation{"Queue implementation"}
Implementation -->|MPSC| Mpsc["JCTools MPSC offer(item)"]
Implementation -->|bounded MPMC| Mpmc["JCTools strict MPMC offer(item)"]
Implementation -->|unbounded MPMC| Linked["ConcurrentLinkedQueue.offer(item)"]
Mpsc -->|true| Stored["Item stored"]
Mpsc -->|false| Refused["Caller returns permit; reports failure"]
Linked --> Stored
Mpmc -->|true| Stored
Mpmc -->|false| Retry["storeRetrying(): up to 63 additional offer calls"]
Retry -->|one returns true| Stored
Retry -->|all return false| Refused
classDef storage fill:#EDE9FE,stroke:#7C3AED,color:#111827,font-family:monospace
classDef refusal fill:#FEE2E2,stroke:#DC2626,color:#111827,font-family:monospace
class Store,Implementation,Mpsc,Mpmc,Linked,Retry,Stored storage
class Refused refusal
The MPMC implementation makes at most 64 calls to offer() in total.
Resubmitting after a consumer failure
A processing retry enqueues an item again after its consumer throws. Resubmission requires a new permit. A caller must later invoke a
process...()method to process the item again.
The diagram separates the queue's call to onFailure() from the strategy's optional call to retry(item).
The dotted arrow represents optional call.
flowchart TD
Process["processOrRetry(consumer, strategy)"] --> Take["take(): remove item and return its permit"]
Take --> Item{"Item found?"}
Item -->|No| Empty["Return false"]
Item -->|Yes| Consume["consumer.accept(item)"]
Consume -->|returns normally| Done["Return true"]
Consume -->|throws| Strategy["onFailure(item, failureCount + 1, failure, retryQueue)"]
Strategy --> Retry{"Strategy calls retry(item)?"}
Retry -->|No| GiveUp["Strategy abandons item or handles it elsewhere"]
Retry -->|Yes| Readmit["Attach failure count; request a new permit"]
Readmit --> Granted{"Permit obtained and item stored?"}
Granted -->|Yes| Requeued["Item waits in queue"]
Granted -->|No| RetryRefused["retry(item) returns false"]
RetryRefused --> Decide["Strategy decides what to do next"]
GiveUp --> Done
Decide --> Done
Requeued --> Done
Requeued -.->|later process call| Take
classDef api fill:#DBEAFE,stroke:#2563EB,color:#111827,font-family:monospace
classDef permit fill:#FEF3C7,stroke:#D97706,color:#111827,font-family:monospace
classDef retry fill:#DCFCE7,stroke:#16A34A,color:#111827,font-family:monospace
classDef refusal fill:#FEE2E2,stroke:#DC2626,color:#111827,font-family:monospace
class Process,Empty,Done api
class Take,Item,Readmit,Granted permit
class Consume,Strategy,Retry,Requeued,Decide retry
class GiveUp,RetryRefused refusal
MaxRetries(3) allows up to three resubmissions. If all three are accepted and later processed with that strategy, the consumer can be invoked four times. The first consumer failure is reported to the strategy as failure count 1.
The processing methods handle consumer failures as follows:
| Method | When the consumer throws |
|---|---|
process() |
Propagates the exception. |
processOrHandle() |
Calls the exception handler. |
processOrRetry() |
Calls the retry strategy, which can resubmit the item. |
In every case, the item has already been removed from the queue.
| 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.
suggestion: Could we validate required callbacks before take()? I reproduced the swallowed-error case on all three backings at the current head:
queue.tryPut("item");
queue.processOrHandle((Consumer<String>) null, (item, failure) -> {});This returns true, leaves size() == 0, and passes an internal NullPointerException to the handler. The caller receives neither the promised validation failure nor the item back. A null retry strategy is also accepted as the plain-processing mode. Public argument validation would keep invalid calls from consuming work; the internal null sentinels can stay private.
JCTools' strict MpmcArrayQueue.offer refuses only when the ring's indexes say it is full. Places outstanding never exceed the ring's capacity, and a consumer advances the consumer index before its place is given back, so a producer holding a place always finds room. A slot a consumer has claimed but not released is waited out inside offer rather than reported, so the bounded retry never ran and bounded nothing. Verified by making the retry path throw and running the MPMC stress test as written and at capacity 2 with eight producers and eight consumers; it was never reached. The 0.24% refusal-with-room figure on Queues.mpmcArrayQueue was measured on the raw ring against a caller-side occupancy count, so that note stays, reworded to say what it measured. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Completion is a plain flag, so filling or closing a reservation from a second thread can store two elements against one place, or store one whose place was already given back. The single-thread assumption was stated only in a private comment; it is now part of the public Reservation contract and the tryReserve Javadoc. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A null consumer, retry strategy or exception handler doubles as one of consume's internal dispatch sentinels, so passing one was taken as a request for a different processing mode rather than rejected. A null function object is the caller's bug, and checking every entry point would cost each call inlining budget, so producers, consumers, retry strategies and exception handlers are now marked @nonnull and the optional reject handler @nullable. Elements are still checked. The WorkQueue Javadoc no longer promises that every null callback throws. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The queue has no drop counter any more. A refused reservation ignores its fill, so the caller checks granted() and counts any discarded element itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxRetries was a public type for one line of policy. It is now package-private behind a static factory on RetryStrategy, kept as a named class rather than a lambda so it reads as itself in compilation logs. A caller holding the result in a static final, or calling the factory inline at the processing call site, still gives the compiler the strategy's exact class. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shorter contracts for the transforming batch, tryReserve, both bounded factories, claimPlaces, store and RejectHandler, following review suggestions. tryReserve keeps the reserving-thread rule, and store keeps that neither shipped backing refuses a claimed element. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The introduction asked whether the reservation survives escape analysis and then read survival as the allocation-free outcome. An object that survives escape analysis is allocated; say which outcome means what. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The drain thread is untimed, so steady measures admission attempts, refusals included. Many producers and one consumer is also exactly what the MPSC ring is built for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It linked to a consume method that does not exist and promised the queue is never full, which the benchmark's own notes contradict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The drop counter is gone, and the class also creates an unbounded queue. Keep the point that the backing is an implementation detail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It does not bound how many neighbours are refused while a claim is held, and the neighbour measurement lives in ContendedBatchAdmissionBenchmark, not ContendedAdmissionBenchmark. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reservation is scalar-replaced in AdmissionBenchmark, and a capturing producer that is reused, or that inlines, need not allocate per admission. Prefer non-capturing producers as @strategy does, rather than requiring them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only work that claimed capacity before the close can store after the discard: a retry lease alone cannot, since retry claims a place and a closed queue refuses the claim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The call-site catalogue argued for the API rather than describing it, and named classes that will change as they migrate. What remains is the admission, refusal, processing and nullability contract. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds fill(supplier, 1) arms through Queues.mpscArrayQueue: the ring also claims a slot before calling its supplier, so steadyRawMpscFill against steadyWorkQueue is what the permit counter and the API cost, about 13ns with no extra allocation. The refused fill arm shows the incumbents' refusal cost is the element they build first, not the queue, which retires the old caveat that blamed the ring. Re-ran every arm together (JDK 17, two forks) and reframed the decision around the guarantees rather than speed, dropping the stale mentions of a drop counter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Name the benchmark-only backing for what it does to store/retrieve's call sites rather than for its position. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
More details
Admission claims capacity before invoking producers, and cleanup returns unused permits on decline or failure. Reservations require thread confinement, and shutdown requires caller coordination to guarantee an empty queue.
🤖 Bits Code Review · Commit 47038d9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| assertEquals(Arrays.asList("a", "b"), consumed); | ||
| } | ||
|
|
||
| // A reservation claims capacity on every backing; only the array backing also holds position. |
There was a problem hiding this comment.
--- Found with Codex
polish: Could we update the reservation comments at lines 635, 663–665, and 697 to match the current implementation?
They describe an array reservation holding a ring position, preserving its reservation order, and returning capacity only when the consumer drains it. PlaceReservation now reserves capacity without holding a backing position: close() immediately releases an unfilled claim, and the element joins the queue when fill() stores it. The assertions here already exercise that behavior correctly; the comments describe the older design.
There was a problem hiding this comment.
Good catch, those comments were left over from the position-holding design. Fixed in a4bef25: all three now say a reservation claims capacity, not a position. close() gives an unfilled claim back at once and the element joins at fill(). No assertions changed, and the contract tests pass on all backings.
Written by Claude, reviewed by @dougqh
AlexeyKuznetsov-DD
left a comment
There was a problem hiding this comment.
LGTM, left minor comment found by Codex.
A reservation claims capacity without holding a backing position: close() releases an unfilled claim at once and the element joins at fill(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
❌ Pull Request Too Large This pull request adds 3192 lines while the limit is 1000 lines. Please split it into smaller stacked pull requests, each one targeting the branch of the previous one. See the pull request size guidelines for more details. If this PR cannot be split, exceptionnaly add the |
Adds contract tests for the context-taking process/processOrRetry/processOrHandle forms and their empty-queue results, a throwing two-context producer, a null in an array batch, a throwing producer mid-batch, a collection whose size() overstates what it yields, and refused stores in batches. Switches the two existing refusing-backing tests to an imported @test. Co-Authored-By: Claude Opus 5.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,RetryStrategy.maxRetries,ExceptionHandler). Plainprocesspropagates; the queue never logs and takes no view on a failure it was not given one for.Three factories over one bound:
WorkQueues.createMpscQueue(jctools MPSC ring, the preferred path),createMpmcQueue(jctools MPMC ring, for multiple consumers) andcreateUnboundedMpmcQueue(aConcurrentLinkedQueue, for call sites with no defensible capacity yet). All are bounded by the same permit counter, which also makessize()O(1). Function-object parameters are@Nonnulland not checked; elements are checked before any capacity is claimed.Tests:
WorkQueueContractTestruns the shared contract against every backing, plusMpscWorkQueueStressTest. Benchmarks:AdmissionBenchmark(admission forms, reservation escape analysis, the third-backing inlining cliff),ContendedAdmissionBenchmark/ContendedBatchAdmissionBenchmark(the capacity boundary under contention), andAdmissionAlternativesBenchmark(against the queues it replaces and the ring's ownfill).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 every backing, 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 cost — about 13ns per admit-and-drain over the ring's own claim-before-build (AdmissionAlternativesBenchmark, JDK 17: 35.8 vs 22.9ns, no extra allocation), and ~5ns on refusal — 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.Possible applications
WafMetricCollector,CoreMetricCollector): claiming first means a delta is never destroyed for an element the queue then refuses.offer's return (RumInjectorMetrics): batch admission turns a per-element dropped return into one count.CoreMetricCollector'sremainingCapacity() == 0): the claim is the check, so there's no check-then-act race.LinkedBlockingQueues (ProductChangeCollector,IntegrationsCollector): a bound becomes the default, and unbounded becomes an explicit choice.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.AdmissionBenchmarkis@Threads(1)andScope.Thread: it prices the admission forms and the third-backing cliff uncontended, and leaves contention toContendedAdmissionBenchmark. Its reservation arms 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)(nowRetryStrategy.maxRetries(n)) 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 likeRetryStrategy.maxRetriesthe 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(a client-side stats use is planned),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 isclose(); discardAll();— producers, reservations, and retries that claimed capacity before the close 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.offerrefuses only at index-full, and a producer holding a permit can never see that, so the retry could never run; a stress run with the retry path made to throw (capacity 2, 8×8 threads) never reached it. AReservationis confined to the reserving thread — completion is a plain flag, and the rule is now in its public contract rather than a private comment.🤖 Generated with Claude Code