@@ -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