Skip to content

Commit 50da24e

Browse files
test(spanner): unflake AsyncResultSetImplTest (#14656)
Production busy-spin hang in AsyncResultSetImpl when a ReadyCallback throws an exception, causing ProduceRowsRunnable to loop infinitely when buffer is full. Triggered by test race on paused flag in testCallbackIsNotCalledWhilePaused. Fixed by including cursorReturnedDoneOrException in producer stop condition and adding lock-based test synchronization.
1 parent 6183b63 commit 50da24e

2 files changed

Lines changed: 57 additions & 5 deletions

File tree

‎java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/AsyncResultSetImpl.java‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ public void run() {
373373
while (!stop && hasNext) {
374374
try {
375375
synchronized (monitor) {
376-
stop = state.shouldStop;
376+
stop = shouldStopProducing();
377377
}
378378
if (!stop) {
379379
while (buffer.remainingCapacity() == 0 && !stop) {
@@ -390,7 +390,7 @@ public void run() {
390390
MAX_WAIT_FOR_BUFFER_CONSUMPTION));
391391
bufferConsumptionLatch.await();
392392
synchronized (monitor) {
393-
stop = state.shouldStop;
393+
stop = shouldStopProducing();
394394
}
395395
}
396396
}
@@ -450,6 +450,15 @@ public void run() {
450450
}
451451
}
452452

453+
/**
454+
* Returns true if no more rows should be produced. This is the case if the result set has been
455+
* cancelled or is done, or if the callback will not be invoked anymore (e.g. because it threw
456+
* an exception). Must be called while holding the monitor.
457+
*/
458+
private boolean shouldStopProducing() {
459+
return state.shouldStop || cursorReturnedDoneOrException;
460+
}
461+
453462
private void waitIfPaused() throws InterruptedException {
454463
CountDownLatch pause;
455464
synchronized (monitor) {

‎java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/AsyncResultSetImplTest.java‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,11 @@ public Boolean answer(InvocationOnMock invocation) throws Throwable {
334334
final AtomicInteger callbackCounter = new AtomicInteger();
335335
final BlockingDeque<Object> queue = new LinkedBlockingDeque<>(1);
336336
final AtomicBoolean paused = new AtomicBoolean();
337+
// Clearing the paused flag and calling resume() must be atomic with respect to the callback
338+
// setting the paused flag. Otherwise, the test thread could clear the flag before the callback
339+
// sets it, and then call resume() after the callback returned PAUSE. The next callback would
340+
// then see a stale paused flag, even though resume() was correctly called.
341+
final Object pauseLock = new Object();
337342
try (AsyncResultSetImpl rs =
338343
new AsyncResultSetImpl(simpleProvider, delegate, AsyncResultSetImpl.DEFAULT_BUFFER_SIZE)) {
339344
ApiFuture<Void> callbackResult =
@@ -345,7 +350,9 @@ public Boolean answer(InvocationOnMock invocation) throws Throwable {
345350
try {
346351
switch (resultSet.tryNext()) {
347352
case OK:
348-
paused.set(true);
353+
synchronized (pauseLock) {
354+
paused.set(true);
355+
}
349356
queue.put(new Object());
350357
return CallbackResponse.PAUSE;
351358
case DONE:
@@ -366,8 +373,10 @@ public Boolean answer(InvocationOnMock invocation) throws Throwable {
366373
rowCounter++;
367374
}
368375
Thread.yield();
369-
paused.set(false);
370-
rs.resume();
376+
synchronized (pauseLock) {
377+
paused.set(false);
378+
rs.resume();
379+
}
371380
}
372381
// Empty the queue to ensure we count all elements.
373382
while (queue.poll() != null) {
@@ -494,6 +503,40 @@ public void callbackReturnsError() throws InterruptedException {
494503
}
495504
}
496505

506+
@Test
507+
public void callbackReturnsErrorWhileBufferIsFull_shouldStopProducer() throws Exception {
508+
ExecutorService executor = Executors.newSingleThreadExecutor();
509+
// Return more rows than fit in the buffer, so the producer will wait for the buffer to be
510+
// consumed by the callback. The callback fails instead of consuming any rows.
511+
final int simulatedRows = AsyncResultSetImpl.DEFAULT_BUFFER_SIZE * 10;
512+
final AtomicInteger producedRows = new AtomicInteger();
513+
ResultSet delegate = mock(ResultSet.class);
514+
when(delegate.next()).thenAnswer(ignored -> producedRows.incrementAndGet() <= simulatedRows);
515+
when(delegate.getCurrentRowAsStruct()).thenReturn(mock(Struct.class));
516+
try (AsyncResultSetImpl rs =
517+
new AsyncResultSetImpl(simpleProvider, delegate, AsyncResultSetImpl.DEFAULT_BUFFER_SIZE)) {
518+
ApiFuture<Void> result =
519+
rs.setCallback(
520+
executor,
521+
ignored -> {
522+
throw new RuntimeException("async test");
523+
});
524+
// The result must complete with the error from the callback. Before the fix, the producer
525+
// would keep waiting for the (full) buffer to be consumed, and the result never completed.
526+
ExecutionException e =
527+
assertThrows(ExecutionException.class, () -> result.get(30L, TimeUnit.SECONDS));
528+
assertThat(e.getCause()).isInstanceOf(SpannerException.class);
529+
SpannerException se = (SpannerException) e.getCause();
530+
assertThat(se.getErrorCode()).isEqualTo(ErrorCode.UNKNOWN);
531+
assertThat(se.getMessage()).contains("async test");
532+
// The producer should stop and release the underlying result set.
533+
Mockito.verify(delegate).close();
534+
assertThat(producedRows.get()).isLessThan(simulatedRows);
535+
} finally {
536+
executor.shutdown();
537+
}
538+
}
539+
497540
@Test
498541
public void callbackReturnsDoneBeforeEnd_shouldStopIteration() throws Exception {
499542
Executor executor = Executors.newSingleThreadExecutor();

0 commit comments

Comments
 (0)