Skip to content

Commit 013b49b

Browse files
authored
fix(spanner): prevent stream leaks and race conditions on result set close (#14665)
Fixes several race conditions, potential stream leaks, and state loss when closing streaming result sets: 1. Early Close Race Condition: If ResultSet.close() was called before asynchronous gRPC stream initialization finished (before setCall was invoked), the cancellation was silently dropped because the call reference was still null. When setCall ran moments later, the stream remained open and was never cancelled. Now, closed state is tracked so that setCall immediately cancels the stream if closed early. 2. Concurrent Close Idempotency: close() is now synchronized and extracts the call reference atomically, guaranteeing call.cancel() is executed at most once, and executing the cancellation outside the lock to prevent deadlocks. 3. Superfluous Cancellation on Completed Streams: The call handle is now cleared as soon as a PartialResultSet with last=true is consumed, avoiding unnecessary RPC cancellations when closing after reading the final chunk without an extra next() call. 4. END_OF_STREAM Queue Deduplication: Atomically deduplicate END_OF_STREAM markers to avoid queue overflow or stalls when prefetchChunks is configured to small values (e.g. prefetchChunks = 1). 5. Inlined Begin Transaction State on Close: In GrpcResultSet.close(), capture withBeginTransaction before closing the underlying iterator so listener.onDone() properly unblocks any threads waiting for inlined transaction IDs.
1 parent 312b5ea commit 013b49b

6 files changed

Lines changed: 1231 additions & 40 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,9 @@ public void close() {
140140
}
141141
closed = true;
142142
}
143-
listener.onDone(iterator.isWithBeginTransaction());
143+
boolean withBeginTransaction = iterator.isWithBeginTransaction();
144144
iterator.close("ResultSet closed");
145+
listener.onDone(withBeginTransaction);
145146
}
146147

147148
@Override

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

Lines changed: 142 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import com.google.common.util.concurrent.Uninterruptibles;
2727
import com.google.spanner.v1.PartialResultSet;
2828
import java.time.Duration;
29+
import java.util.Objects;
2930
import java.util.Optional;
3031
import java.util.concurrent.BlockingQueue;
3132
import java.util.concurrent.LinkedBlockingQueue;
@@ -38,6 +39,40 @@
3839
@VisibleForTesting
3940
class GrpcStreamIterator extends AbstractIterator<PartialResultSet>
4041
implements CloseableIterator<PartialResultSet> {
42+
43+
@VisibleForTesting
44+
static final class Timeout {
45+
final long duration;
46+
final TimeUnit unit;
47+
48+
Timeout(long duration, TimeUnit unit) {
49+
this.duration = duration;
50+
this.unit = Preconditions.checkNotNull(unit);
51+
}
52+
53+
@Override
54+
public boolean equals(Object o) {
55+
if (this == o) {
56+
return true;
57+
}
58+
if (!(o instanceof Timeout)) {
59+
return false;
60+
}
61+
Timeout that = (Timeout) o;
62+
return this.duration == that.duration && this.unit == that.unit;
63+
}
64+
65+
@Override
66+
public int hashCode() {
67+
return Objects.hash(duration, unit);
68+
}
69+
70+
@Override
71+
public String toString() {
72+
return duration + " " + unit;
73+
}
74+
}
75+
4176
private static final Logger logger = Logger.getLogger(GrpcStreamIterator.class.getName());
4277
static final PartialResultSet END_OF_STREAM = PartialResultSet.newBuilder().build();
4378
private final int prefetchChunks;
@@ -47,13 +82,15 @@ class GrpcStreamIterator extends AbstractIterator<PartialResultSet>
4782
private final BlockingQueue<PartialResultSet> stream;
4883
private final Statement statement;
4984

50-
private SpannerRpc.StreamingCall call;
85+
private volatile SpannerRpc.StreamingCall call;
86+
private boolean callCleared;
87+
private boolean closed;
88+
@Nullable private String closeMessage;
5189
private volatile boolean withBeginTransaction;
5290
private final boolean lastStatement;
53-
private TimeUnit streamWaitTimeoutUnit;
54-
private long streamWaitTimeoutValue;
55-
private SpannerException error;
56-
private boolean done;
91+
@Nullable private volatile Timeout streamWaitTimeout;
92+
@Nullable private volatile SpannerException error;
93+
private volatile boolean done;
5794

5895
@VisibleForTesting
5996
GrpcStreamIterator(
@@ -84,8 +121,28 @@ void registerListener(AsyncResultSet.StreamMessageListener streamMessageListener
84121
}
85122

86123
public void setCall(SpannerRpc.StreamingCall call, boolean withBeginTransaction) {
87-
this.call = call;
88-
this.withBeginTransaction = withBeginTransaction;
124+
Timeout timeout = extractTimeout(call);
125+
boolean shouldCancel;
126+
String messageToCancel;
127+
synchronized (this) {
128+
this.withBeginTransaction = withBeginTransaction;
129+
shouldCancel = this.closed && !this.callCleared;
130+
if (!this.callCleared && !this.closed) {
131+
this.call = call;
132+
}
133+
if (timeout != null) {
134+
this.streamWaitTimeout = timeout;
135+
}
136+
messageToCancel = this.closeMessage;
137+
}
138+
if (shouldCancel) {
139+
call.cancel(messageToCancel);
140+
}
141+
}
142+
143+
@VisibleForTesting
144+
@Nullable
145+
static Timeout extractTimeout(SpannerRpc.StreamingCall call) {
89146
ApiCallContext callContext = call.getCallContext();
90147
Duration streamWaitTimeout =
91148
callContext == null ? null : callContext.getStreamWaitTimeoutDuration();
@@ -94,29 +151,52 @@ public void setCall(SpannerRpc.StreamingCall call, boolean withBeginTransaction)
94151
// value is more than 1 second, which is lower than the precision that would normally be
95152
// used by the stream watchdog (which uses a precision of 10 seconds by default).
96153
if (streamWaitTimeout.getSeconds() > 0L) {
97-
streamWaitTimeoutValue = streamWaitTimeout.getSeconds();
98-
streamWaitTimeoutUnit = TimeUnit.SECONDS;
154+
return new Timeout(streamWaitTimeout.getSeconds(), TimeUnit.SECONDS);
99155
} else if (streamWaitTimeout.getNano() > 0) {
100-
streamWaitTimeoutValue = streamWaitTimeout.getNano();
101-
streamWaitTimeoutUnit = TimeUnit.NANOSECONDS;
156+
return new Timeout(streamWaitTimeout.getNano(), TimeUnit.NANOSECONDS);
102157
}
103158
// Note that if the stream-wait-timeout is zero, we won't set a timeout at all.
104159
// That is consistent with ApiCallContext#withStreamWaitTimeout(Duration.ZERO).
105160
}
161+
return null;
106162
}
107163

108164
@Override
109165
public void close(@Nullable String message) {
110-
if (call != null) {
111-
call.cancel(message);
166+
SpannerRpc.StreamingCall callToCancel = null;
167+
synchronized (this) {
168+
if (!closed) {
169+
closed = true;
170+
closeMessage = message;
171+
if (!callCleared) {
172+
callToCancel = this.call;
173+
this.call = null;
174+
}
175+
}
176+
}
177+
if (callToCancel != null) {
178+
callToCancel.cancel(message);
112179
}
113180
}
114181

115182
@Override
116183
@InternalApi
117184
public void requestPrefetchChunks() {
118-
Preconditions.checkState(call != null, "The StreamingCall object is not initialized");
119-
call.request(prefetchChunks);
185+
SpannerRpc.StreamingCall callToRequest = null;
186+
synchronized (this) {
187+
if (this.call != null) {
188+
callToRequest = this.call;
189+
} else if (!this.closed && !this.callCleared) {
190+
// This should never happen during normal operation because startStream(...) always
191+
// initializes the call via setCall(...) before returning the GrpcStreamIterator and
192+
// calling requestPrefetchChunks(). Reaching this point means requestPrefetchChunks()
193+
// was invoked before setCall(...).
194+
throw new IllegalStateException("The StreamingCall object is not initialized");
195+
}
196+
}
197+
if (callToRequest != null) {
198+
callToRequest.request(prefetchChunks);
199+
}
120200
}
121201

122202
@Override
@@ -131,35 +211,51 @@ public boolean isLastStatement() {
131211

132212
@Override
133213
protected final PartialResultSet computeNext() {
134-
PartialResultSet next;
214+
PartialResultSet next = takeNextFromStream();
215+
if (next != END_OF_STREAM) {
216+
SpannerRpc.StreamingCall currentCall = this.call;
217+
if (currentCall != null) {
218+
currentCall.request(1);
219+
}
220+
if (next.getLast()) {
221+
clearCall();
222+
}
223+
return next;
224+
}
225+
226+
clearCall();
227+
if (error != null) {
228+
throw SpannerExceptionFactory.asSpannerException(error);
229+
}
230+
endOfData();
231+
return null;
232+
}
233+
234+
private PartialResultSet takeNextFromStream() {
135235
try {
136-
if (streamWaitTimeoutUnit != null) {
137-
next = stream.poll(streamWaitTimeoutValue, streamWaitTimeoutUnit);
236+
Timeout timeout = this.streamWaitTimeout;
237+
if (timeout != null) {
238+
PartialResultSet next = stream.poll(timeout.duration, timeout.unit);
138239
if (next == null) {
139240
throw SpannerExceptionFactory.newSpannerException(
140241
ErrorCode.DEADLINE_EXCEEDED, "stream wait timeout");
141242
}
243+
return next;
142244
} else {
143-
next = stream.take();
245+
return stream.take();
144246
}
145247
} catch (InterruptedException e) {
146248
// Treat interrupt as a request to cancel the read.
147249
throw SpannerExceptionFactory.propagateInterrupt(e);
148250
}
149-
if (next != END_OF_STREAM) {
150-
call.request(1);
151-
return next;
152-
}
251+
}
153252

253+
private void clearCall() {
154254
// All done - close() no longer needs to cancel the call.
155-
call = null;
156-
157-
if (error != null) {
158-
throw SpannerExceptionFactory.asSpannerException(error);
255+
synchronized (this) {
256+
callCleared = true;
257+
call = null;
159258
}
160-
161-
endOfData();
162-
return null;
163259
}
164260

165261
private void addToStream(PartialResultSet results) {
@@ -179,16 +275,13 @@ private class ConsumerImpl implements SpannerRpc.ResultStreamConsumer {
179275
public void onPartialResultSet(PartialResultSet results) {
180276
addToStream(results);
181277
if (results.getLast()) {
182-
done = true;
183-
addToStream(END_OF_STREAM);
278+
addEndOfStream();
184279
}
185280
}
186281

187282
@Override
188283
public void onCompleted() {
189-
if (!done) {
190-
addToStream(END_OF_STREAM);
191-
}
284+
addEndOfStream();
192285
}
193286

194287
@Override
@@ -203,7 +296,20 @@ public void onError(SpannerException exception) {
203296
}
204297
}
205298
error = exception;
206-
addToStream(END_OF_STREAM);
299+
addEndOfStream();
300+
}
301+
302+
private void addEndOfStream() {
303+
boolean shouldAddEndOfStream = false;
304+
synchronized (GrpcStreamIterator.this) {
305+
if (!done) {
306+
done = true;
307+
shouldAddEndOfStream = true;
308+
}
309+
}
310+
if (shouldAddEndOfStream) {
311+
addToStream(END_OF_STREAM);
312+
}
207313
}
208314

209315
@Override

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

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@
4646
public class CloseSpannerWithOpenResultSetTest extends AbstractMockServerTest {
4747

4848
Spanner createSpanner() {
49+
return spannerOptionsBuilder().build().getService();
50+
}
51+
52+
Spanner createSpanner(int prefetchChunks) {
53+
return spannerOptionsBuilder().setPrefetchChunks(prefetchChunks).build().getService();
54+
}
55+
56+
private SpannerOptions.Builder spannerOptionsBuilder() {
4957
return SpannerOptions.newBuilder()
5058
.setProjectId("p")
5159
.setHost(String.format("http://localhost:%d", getPort()))
@@ -54,9 +62,7 @@ Spanner createSpanner() {
5462
.setSessionPoolOption(
5563
SessionPoolOptions.newBuilder()
5664
.setWaitForMinSessionsDuration(Duration.ofSeconds(5L))
57-
.build())
58-
.build()
59-
.getService();
65+
.build());
6066
}
6167

6268
@BeforeClass
@@ -122,4 +128,65 @@ public void testStreamsAreCleanedUp() throws Exception {
122128
0, ((GapicSpannerRpc) ((SpannerImpl) spanner).getRpc()).getNumActiveResponseObservers());
123129
}
124130
}
131+
132+
@Test(timeout = 30000)
133+
public void testQueryWithPrefetchChunksOne() {
134+
try (Spanner spanner = createSpanner(1)) {
135+
DatabaseClient client = spanner.getDatabaseClient(DatabaseId.of("p", "i", "d"));
136+
for (int i = 0; i < 10; i++) {
137+
try (ResultSet resultSet = client.singleUse().executeQuery(SELECT_RANDOM_STATEMENT)) {
138+
int count = 0;
139+
while (resultSet.next()) {
140+
assertNotNull(resultSet.getCurrentRowAsStruct());
141+
count++;
142+
}
143+
assertTrue(count > 0);
144+
}
145+
}
146+
assertEquals(
147+
0, ((GapicSpannerRpc) ((SpannerImpl) spanner).getRpc()).getNumActiveResponseObservers());
148+
}
149+
}
150+
151+
@Test(timeout = 30000)
152+
public void testQueryWithPrefetchChunksOneAndError() {
153+
String errorSql = "select * from will_fail";
154+
Statement errorStatement = Statement.of(errorSql);
155+
mockSpanner.putStatementResult(
156+
StatementResult.exception(
157+
errorStatement,
158+
Status.INVALID_ARGUMENT.withDescription("Invalid syntax").asRuntimeException()));
159+
try (Spanner spanner = createSpanner(1)) {
160+
DatabaseClient client = spanner.getDatabaseClient(DatabaseId.of("p", "i", "d"));
161+
try (ResultSet resultSet = client.singleUse().executeQuery(errorStatement)) {
162+
SpannerException exception = assertThrows(SpannerException.class, resultSet::next);
163+
assertEquals(ErrorCode.INVALID_ARGUMENT, exception.getErrorCode());
164+
}
165+
assertEquals(
166+
0, ((GapicSpannerRpc) ((SpannerImpl) spanner).getRpc()).getNumActiveResponseObservers());
167+
}
168+
}
169+
170+
@Test(timeout = 30000)
171+
public void testCloseResultSetCleansUpResponseObservers() {
172+
try (Spanner spanner = createSpanner()) {
173+
DatabaseClient client = spanner.getDatabaseClient(DatabaseId.of("p", "i", "d"));
174+
// 1. Partial consumption then close:
175+
try (ResultSet resultSet = client.singleUse().executeQuery(SELECT_RANDOM_STATEMENT)) {
176+
assertTrue(resultSet.next());
177+
assertNotNull(resultSet.getCurrentRowAsStruct());
178+
}
179+
assertEquals(
180+
0, ((GapicSpannerRpc) ((SpannerImpl) spanner).getRpc()).getNumActiveResponseObservers());
181+
182+
// 2. Full consumption then close:
183+
try (ResultSet resultSet = client.singleUse().executeQuery(SELECT_RANDOM_STATEMENT)) {
184+
while (resultSet.next()) {
185+
assertNotNull(resultSet.getCurrentRowAsStruct());
186+
}
187+
}
188+
assertEquals(
189+
0, ((GapicSpannerRpc) ((SpannerImpl) spanner).getRpc()).getNumActiveResponseObservers());
190+
}
191+
}
125192
}

0 commit comments

Comments
 (0)