Demonstrates BlockingQueueDetector: a bounded queue held at its capacity by a
fire-and-forget producer, plus the null-dereference crash that comes from using
non-blocking poll() without a guard.
WorkQueueService wraps an ArrayBlockingQueue with capacity 5.
- Producer calls
offer(task), which returnsfalsewhen the queue is full. Callers routinely ignore that return, so tasks vanish with no error. - Consumer calls
poll(), which returnsnullwhen the queue is empty.processNext()callstoUpperCase()on the result without a null check, so an empty queue is aNullPointerException.
Under sequential load the queue never fills, so @Test passes. Under concurrent load
(8 threads x 50 invocations) with nothing draining it, the queue reaches 5/5 on the first
round and stays there.
This is worth reading before the reproduction steps, because this example used to promise something the library deliberately stopped saying.
A rejected offer() is not a finding. This is what correct backpressure looks like:
if (!queue.offer(task)) {
retryLater(task); // the return value was read, and acted on
}BlockingQueueDetector counts rejections and shows the count, but does not gate on it,
because gating on it printed "BLOCKING QUEUE ISSUES DETECTED" for every correct bounded
queue. The same goes for a null poll(): the textbook drain loop
while ((x = q.poll()) != null) ends with exactly one null every time.
Saturation is a finding. A queue sitting at its bound says the sizing or the drain rate
is wrong, and that is what hasIssues() reports on. This demonstration produces it, and the
rejection count appears alongside it in the same report.
- Remove
@DisabledfromtestWorkQueue_concurrent_detectsSaturation. - Run:
mvn testor./gradlew test - The test fails:
BLOCKING QUEUE ISSUES DETECTED:
Silent Failures (offer returned false):
- work-queue: offer() returned false 395 times (queue full; check the caller handles
the rejected item)
Queue Saturation:
- work-queue: queue reached 5/5 capacity (saturation risk)
Queue Activity:
- work-queue: offers: 400 (success: 5, failed: 395), polls: 0 ..., max size: 5
failOn = FailOn.LOW is what turns that report into a failed run.
Fix: replace offer() with put() (blocks until space is available) and replace
poll() with poll(timeout, unit) or take(), adding a null-check guard where a timeout
poll is required. If the queue really must be lossy, read the offer() return and record
the drop rather than discarding it silently.
WorkQueueService.java- the bounded queue,offer()on the way in and unguardedpoll()on the way outWorkQueueServiceTest.javatestProcessNext_emptyQueue_throwsNullPointerException()- the half of the bug that needs no detector at alltestBlockingQueueDetector_queueAtItsBound_reports()- the detector's positive directiontestBlockingQueueDetector_queueWellBelowItsBound_isSilent()- and its negative onetestWorkQueue_concurrent_detectsSaturation()- the@AsyncTestdemonstration