Skip to content

Commit 3513cd3

Browse files
fix: retry Query streams (#426)
1 parent 078dd57 commit 3513cd3

File tree

4 files changed

+186
-9
lines changed

4 files changed

+186
-9
lines changed

google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@
3232
import com.google.api.core.InternalExtensionOnly;
3333
import com.google.api.core.SettableApiFuture;
3434
import com.google.api.gax.rpc.ApiStreamObserver;
35+
import com.google.api.gax.rpc.StatusCode;
3536
import com.google.auto.value.AutoValue;
3637
import com.google.cloud.Timestamp;
3738
import com.google.cloud.firestore.Query.QueryOptions.Builder;
39+
import com.google.cloud.firestore.v1.FirestoreSettings;
3840
import com.google.common.base.Preconditions;
3941
import com.google.common.collect.ImmutableList;
4042
import com.google.common.collect.ImmutableMap;
@@ -52,14 +54,17 @@
5254
import com.google.firestore.v1.Value;
5355
import com.google.protobuf.ByteString;
5456
import com.google.protobuf.Int32Value;
57+
import io.grpc.Status;
5558
import io.opencensus.trace.AttributeValue;
5659
import io.opencensus.trace.Tracing;
5760
import java.util.ArrayList;
5861
import java.util.Comparator;
5962
import java.util.Iterator;
6063
import java.util.List;
6164
import java.util.Objects;
65+
import java.util.Set;
6266
import java.util.concurrent.Executor;
67+
import java.util.concurrent.atomic.AtomicReference;
6368
import javax.annotation.Nonnull;
6469
import javax.annotation.Nullable;
6570

@@ -1297,7 +1302,8 @@ public void onCompleted() {
12971302
responseObserver.onCompleted();
12981303
}
12991304
},
1300-
null);
1305+
/* transactionId= */ null,
1306+
/* readTime= */ null);
13011307
}
13021308

13031309
/**
@@ -1431,13 +1437,18 @@ Timestamp getReadTime() {
14311437
}
14321438

14331439
private void internalStream(
1434-
final QuerySnapshotObserver documentObserver, @Nullable ByteString transactionId) {
1440+
final QuerySnapshotObserver documentObserver,
1441+
@Nullable final ByteString transactionId,
1442+
@Nullable final Timestamp readTime) {
14351443
RunQueryRequest.Builder request = RunQueryRequest.newBuilder();
14361444
request.setStructuredQuery(buildQuery()).setParent(options.getParentPath().toString());
14371445

14381446
if (transactionId != null) {
14391447
request.setTransaction(transactionId);
14401448
}
1449+
if (readTime != null) {
1450+
request.setReadTime(readTime.toProto());
1451+
}
14411452

14421453
Tracing.getTracer()
14431454
.getCurrentSpan()
@@ -1446,6 +1457,8 @@ private void internalStream(
14461457
ImmutableMap.of(
14471458
"transactional", AttributeValue.booleanAttributeValue(transactionId != null)));
14481459

1460+
final AtomicReference<QueryDocumentSnapshot> lastReceivedDocument = new AtomicReference<>();
1461+
14491462
ApiStreamObserver<RunQueryResponse> observer =
14501463
new ApiStreamObserver<RunQueryResponse>() {
14511464
Timestamp readTime;
@@ -1470,6 +1483,7 @@ public void onNext(RunQueryResponse response) {
14701483
QueryDocumentSnapshot.fromDocument(
14711484
rpcContext, Timestamp.fromProto(response.getReadTime()), document);
14721485
documentObserver.onNext(documentSnapshot);
1486+
lastReceivedDocument.set(documentSnapshot);
14731487
}
14741488

14751489
if (readTime == null) {
@@ -1479,8 +1493,27 @@ public void onNext(RunQueryResponse response) {
14791493

14801494
@Override
14811495
public void onError(Throwable throwable) {
1482-
Tracing.getTracer().getCurrentSpan().addAnnotation("Firestore.Query: Error");
1483-
documentObserver.onError(throwable);
1496+
// If a non-transactional query failed, attempt to restart.
1497+
// Transactional queries are retried via the transaction runner.
1498+
if (transactionId == null && isRetryableError(throwable)) {
1499+
Tracing.getTracer()
1500+
.getCurrentSpan()
1501+
.addAnnotation("Firestore.Query: Retryable Error");
1502+
1503+
// Restart the query but use the last document we received as the
1504+
// query cursor. Note that this it is ok to not use backoff here
1505+
// since we are requiring at least a single document result.
1506+
QueryDocumentSnapshot cursor = lastReceivedDocument.get();
1507+
if (cursor != null) {
1508+
Query.this
1509+
.startAfter(cursor)
1510+
.internalStream(
1511+
documentObserver, /* transactionId= */ null, cursor.getReadTime());
1512+
}
1513+
} else {
1514+
Tracing.getTracer().getCurrentSpan().addAnnotation("Firestore.Query: Error");
1515+
documentObserver.onError(throwable);
1516+
}
14841517
}
14851518

14861519
@Override
@@ -1562,7 +1595,8 @@ public void onCompleted() {
15621595
result.set(querySnapshot);
15631596
}
15641597
},
1565-
transactionId);
1598+
transactionId,
1599+
/* readTime= */ null);
15661600

15671601
return result;
15681602
}
@@ -1624,6 +1658,22 @@ private <T> ImmutableList<T> append(ImmutableList<T> existingList, T newElement)
16241658
return builder.build();
16251659
}
16261660

1661+
/** Verifies whether the given exception is retryable based on the RunQuery configuration. */
1662+
private boolean isRetryableError(Throwable throwable) {
1663+
if (!(throwable instanceof FirestoreException)) {
1664+
return false;
1665+
}
1666+
Set<StatusCode.Code> codes =
1667+
FirestoreSettings.newBuilder().runQuerySettings().getRetryableCodes();
1668+
Status status = ((FirestoreException) throwable).getStatus();
1669+
for (StatusCode.Code code : codes) {
1670+
if (code.equals(StatusCode.Code.valueOf(status.getCode().name()))) {
1671+
return true;
1672+
}
1673+
}
1674+
return false;
1675+
}
1676+
16271677
/**
16281678
* Returns true if this Query is equal to the provided object.
16291679
*

google-cloud-firestore/src/test/java/com/google/cloud/firestore/DocumentReferenceTest.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,10 @@ public void notFound() throws Exception {
347347
getDocumentResponse.setReadTime(
348348
com.google.protobuf.Timestamp.newBuilder().setSeconds(5).setNanos(6));
349349

350-
doAnswer(streamingResponse(getDocumentResponse.build()))
350+
doAnswer(
351+
streamingResponse(
352+
new BatchGetDocumentsResponse[] {getDocumentResponse.build()},
353+
/* throwable= */ null))
351354
.when(firestoreMock)
352355
.streamRequest(
353356
getAllCapture.capture(),

google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ public static Answer<BatchGetDocumentsResponse> getAllResponse(
279279
responses[i] = response.build();
280280
}
281281

282-
return streamingResponse(responses);
282+
return streamingResponse(responses, null);
283283
}
284284

285285
public static ApiFuture<Empty> rollbackResponse() {
@@ -291,6 +291,12 @@ public static Answer<RunQueryResponse> queryResponse() {
291291
}
292292

293293
public static Answer<RunQueryResponse> queryResponse(String... documentNames) {
294+
return queryResponse(/* throwable= */ null, documentNames);
295+
}
296+
297+
/** Returns a stream of documents followed by an optional exception. */
298+
public static Answer<RunQueryResponse> queryResponse(
299+
@Nullable Throwable throwable, String... documentNames) {
294300
RunQueryResponse[] responses = new RunQueryResponse[documentNames.length];
295301

296302
for (int i = 0; i < documentNames.length; ++i) {
@@ -301,17 +307,23 @@ public static Answer<RunQueryResponse> queryResponse(String... documentNames) {
301307
com.google.protobuf.Timestamp.newBuilder().setSeconds(1).setNanos(2));
302308
responses[i] = runQueryResponse.build();
303309
}
304-
return streamingResponse(responses);
310+
311+
return streamingResponse(responses, throwable);
305312
}
306313

307-
public static <T> Answer<T> streamingResponse(final T... response) {
314+
/** Returns a stream of responses followed by an optional exception. */
315+
public static <T> Answer<T> streamingResponse(
316+
final T[] response, @Nullable final Throwable throwable) {
308317
return new Answer<T>() {
309318
public T answer(InvocationOnMock invocation) {
310319
Object[] args = invocation.getArguments();
311320
ApiStreamObserver<T> observer = (ApiStreamObserver<T>) args[1];
312321
for (T resp : response) {
313322
observer.onNext(resp);
314323
}
324+
if (throwable != null) {
325+
observer.onError(throwable);
326+
}
315327
observer.onCompleted();
316328
return null;
317329
}

google-cloud-firestore/src/test/java/com/google/cloud/firestore/QueryTest.java

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,17 +46,20 @@
4646
import com.google.common.io.BaseEncoding;
4747
import com.google.firestore.v1.ArrayValue;
4848
import com.google.firestore.v1.RunQueryRequest;
49+
import com.google.firestore.v1.RunQueryResponse;
4950
import com.google.firestore.v1.StructuredQuery;
5051
import com.google.firestore.v1.StructuredQuery.Direction;
5152
import com.google.firestore.v1.StructuredQuery.FieldFilter.Operator;
5253
import com.google.firestore.v1.Value;
5354
import com.google.protobuf.InvalidProtocolBufferException;
55+
import io.grpc.Status;
5456
import java.lang.reflect.InvocationHandler;
5557
import java.lang.reflect.Method;
5658
import java.lang.reflect.Proxy;
5759
import java.util.Arrays;
5860
import java.util.Collections;
5961
import java.util.Iterator;
62+
import java.util.List;
6063
import java.util.concurrent.Semaphore;
6164
import org.junit.Before;
6265
import org.junit.Test;
@@ -66,7 +69,9 @@
6669
import org.mockito.Matchers;
6770
import org.mockito.Mockito;
6871
import org.mockito.Spy;
72+
import org.mockito.invocation.InvocationOnMock;
6973
import org.mockito.runners.MockitoJUnitRunner;
74+
import org.mockito.stubbing.Answer;
7075

7176
@RunWith(MockitoJUnitRunner.class)
7277
public class QueryTest {
@@ -902,6 +907,113 @@ public void onCompleted() {
902907
semaphore.acquire();
903908
}
904909

910+
@Test
911+
public void retriesAfterRetryableError() throws Exception {
912+
final boolean[] returnError = new boolean[] {true};
913+
914+
doAnswer(
915+
new Answer<RunQueryResponse>() {
916+
public RunQueryResponse answer(InvocationOnMock invocation) throws Throwable {
917+
if (returnError[0]) {
918+
returnError[0] = false;
919+
return queryResponse(
920+
FirestoreException.serverRejected(
921+
Status.DEADLINE_EXCEEDED, "Simulated test failure"),
922+
DOCUMENT_NAME + "1",
923+
DOCUMENT_NAME + "2")
924+
.answer(invocation);
925+
} else {
926+
return queryResponse(DOCUMENT_NAME + "3").answer(invocation);
927+
}
928+
}
929+
})
930+
.when(firestoreMock)
931+
.streamRequest(
932+
runQuery.capture(),
933+
streamObserverCapture.capture(),
934+
Matchers.<ServerStreamingCallable>any());
935+
936+
// Verify the responses
937+
final Semaphore semaphore = new Semaphore(0);
938+
final Iterator<String> iterator = Arrays.asList("doc1", "doc2", "doc3").iterator();
939+
940+
query.stream(
941+
new ApiStreamObserver<DocumentSnapshot>() {
942+
@Override
943+
public void onNext(DocumentSnapshot documentSnapshot) {
944+
assertEquals(iterator.next(), documentSnapshot.getId());
945+
}
946+
947+
@Override
948+
public void onError(Throwable throwable) {
949+
fail();
950+
}
951+
952+
@Override
953+
public void onCompleted() {
954+
semaphore.release();
955+
}
956+
});
957+
958+
semaphore.acquire();
959+
960+
// Verify the requests
961+
List<RunQueryRequest> requests = runQuery.getAllValues();
962+
assertEquals(2, requests.size());
963+
964+
assertFalse(requests.get(0).hasReadTime());
965+
assertFalse(requests.get(0).getStructuredQuery().hasStartAt());
966+
967+
assertEquals(
968+
com.google.protobuf.Timestamp.newBuilder().setSeconds(1).setNanos(2).build(),
969+
requests.get(1).getReadTime());
970+
assertFalse(requests.get(1).getStructuredQuery().getStartAt().getBefore());
971+
assertEquals(
972+
DOCUMENT_NAME + "2",
973+
requests.get(1).getStructuredQuery().getStartAt().getValues(0).getReferenceValue());
974+
}
975+
976+
@Test
977+
public void doesNotRetryAfterNonRetryableError() throws Exception {
978+
doAnswer(
979+
queryResponse(
980+
FirestoreException.serverRejected(
981+
Status.PERMISSION_DENIED, "Simulated test failure"),
982+
DOCUMENT_NAME + "1",
983+
DOCUMENT_NAME + "2"))
984+
.when(firestoreMock)
985+
.streamRequest(
986+
runQuery.capture(),
987+
streamObserverCapture.capture(),
988+
Matchers.<ServerStreamingCallable>any());
989+
990+
// Verify the responses
991+
final Semaphore semaphore = new Semaphore(0);
992+
final Iterator<String> iterator = Arrays.asList("doc1", "doc2").iterator();
993+
994+
query.stream(
995+
new ApiStreamObserver<DocumentSnapshot>() {
996+
@Override
997+
public void onNext(DocumentSnapshot documentSnapshot) {
998+
assertEquals(iterator.next(), documentSnapshot.getId());
999+
}
1000+
1001+
@Override
1002+
public void onError(Throwable throwable) {
1003+
semaphore.release();
1004+
}
1005+
1006+
@Override
1007+
public void onCompleted() {}
1008+
});
1009+
1010+
semaphore.acquire();
1011+
1012+
// Verify the request count
1013+
List<RunQueryRequest> requests = runQuery.getAllValues();
1014+
assertEquals(1, runQuery.getAllValues().size());
1015+
}
1016+
9051017
@Test
9061018
public void equalsTest() {
9071019
assertEquals(query.limit(42).offset(1337), query.offset(1337).limit(42));

0 commit comments

Comments
 (0)