Skip to content

Commit fff729d

Browse files
committed
fix(spanner): prevent memory leak and thread blocking in transaction keep-alive
- Enable `setRemoveOnCancelPolicy(true)` on `KEEP_ALIVE_SERVICE` so canceled tasks are immediately purged from `DelayedWorkQueue`. - Use a `WeakReference<ReadWriteTransaction>` in `KeepAliveRunnable` to prevent scheduled tasks from retaining strong references to transaction instances. - Use `abortedLock.tryLock()` in `KeepAliveRunnable` so the shared executor thread does not block when a transaction is active or retrying. - Remove duplicate `maybeScheduleKeepAlivePing` listener registration on keep-alive query completion. - Add unit tests in `ReadWriteTransactionTest` verifying task removal on cancel, weak reference retention, non-blocking lock handling, and single ping scheduling on completion.
1 parent d586d07 commit fff729d

2 files changed

Lines changed: 252 additions & 17 deletions

File tree

java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ReadWriteTransaction.java

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,15 @@
6565
import io.grpc.Deadline;
6666
import io.opentelemetry.api.common.AttributeKey;
6767
import io.opentelemetry.context.Scope;
68+
import java.lang.ref.WeakReference;
6869
import java.time.Duration;
6970
import java.util.ArrayList;
7071
import java.util.LinkedList;
7172
import java.util.List;
7273
import java.util.Objects;
7374
import java.util.concurrent.Callable;
74-
import java.util.concurrent.Executors;
75-
import java.util.concurrent.ScheduledExecutorService;
7675
import java.util.concurrent.ScheduledFuture;
76+
import java.util.concurrent.ScheduledThreadPoolExecutor;
7777
import java.util.concurrent.ThreadFactory;
7878
import java.util.concurrent.ThreadLocalRandom;
7979
import java.util.concurrent.TimeUnit;
@@ -100,8 +100,20 @@ class ReadWriteTransaction extends AbstractMultiUseTransaction {
100100
private static final ThreadFactory KEEP_ALIVE_THREAD_FACTORY =
101101
ThreadFactoryUtil.createVirtualOrPlatformDaemonThreadFactory(
102102
"read-write-transaction-keep-alive", true);
103-
private static final ScheduledExecutorService KEEP_ALIVE_SERVICE =
104-
Executors.newSingleThreadScheduledExecutor(KEEP_ALIVE_THREAD_FACTORY);
103+
private static final ScheduledThreadPoolExecutor KEEP_ALIVE_SERVICE = createKeepAliveService();
104+
105+
private static ScheduledThreadPoolExecutor createKeepAliveService() {
106+
ScheduledThreadPoolExecutor executor =
107+
new ScheduledThreadPoolExecutor(1, KEEP_ALIVE_THREAD_FACTORY);
108+
executor.setRemoveOnCancelPolicy(true);
109+
return executor;
110+
}
111+
112+
@VisibleForTesting
113+
static ScheduledThreadPoolExecutor getKeepAliveService() {
114+
return KEEP_ALIVE_SERVICE;
115+
}
116+
105117
private static final ParsedStatement SELECT1_STATEMENT =
106118
AbstractStatementParser.getInstance(Dialect.GOOGLE_STANDARD_SQL)
107119
.parse(Statement.of("SELECT 1"));
@@ -146,7 +158,7 @@ class ReadWriteTransaction extends AbstractMultiUseTransaction {
146158
private Savepoint autoSavepoint;
147159

148160
private final int maxInternalRetries;
149-
private final ReentrantLock abortedLock = new ReentrantLock();
161+
@VisibleForTesting final ReentrantLock abortedLock = new ReentrantLock();
150162
private final long transactionId;
151163
private final DatabaseClient dbClient;
152164
private final TransactionOption[] transactionOptions;
@@ -468,14 +480,15 @@ private boolean shouldPing() {
468480
&& rolledBackToSavepointException == null;
469481
}
470482

471-
private void maybeScheduleKeepAlivePing() {
483+
@VisibleForTesting
484+
void maybeScheduleKeepAlivePing() {
472485
if (shouldPing()) {
473486
keepAliveLock.lock();
474487
try {
475-
if (keepAliveFuture == null || keepAliveFuture.isDone()) {
488+
if (shouldPing() && (keepAliveFuture == null || keepAliveFuture.isDone())) {
476489
keepAliveFuture =
477490
KEEP_ALIVE_SERVICE.schedule(
478-
new KeepAliveRunnable(),
491+
new KeepAliveRunnable(this),
479492
keepAliveIntervalMillis > 0
480493
? keepAliveIntervalMillis
481494
: DEFAULT_KEEP_ALIVE_INTERVAL_MILLIS,
@@ -487,36 +500,76 @@ private void maybeScheduleKeepAlivePing() {
487500
}
488501
}
489502

503+
@VisibleForTesting
504+
ScheduledFuture<?> getKeepAliveFuture() {
505+
return keepAliveFuture;
506+
}
507+
490508
private void cancelScheduledKeepAlivePing() {
491509
if (keepAliveLock != null) {
492510
keepAliveLock.lock();
493511
try {
494512
if (keepAliveFuture != null) {
495513
keepAliveFuture.cancel(false);
514+
keepAliveFuture = null;
496515
}
497516
} finally {
498517
keepAliveLock.unlock();
499518
}
500519
}
501520
}
502521

503-
private class KeepAliveRunnable implements Runnable {
522+
private void rescheduleKeepAlivePing() {
523+
if (keepAliveLock != null) {
524+
keepAliveLock.lock();
525+
try {
526+
keepAliveFuture = null;
527+
maybeScheduleKeepAlivePing();
528+
} finally {
529+
keepAliveLock.unlock();
530+
}
531+
}
532+
}
533+
534+
@VisibleForTesting
535+
static class KeepAliveRunnable implements Runnable {
536+
final WeakReference<ReadWriteTransaction> transactionRef;
537+
538+
KeepAliveRunnable(ReadWriteTransaction transaction) {
539+
this.transactionRef = new WeakReference<>(transaction);
540+
}
541+
504542
@Override
505543
public void run() {
506-
if (shouldPing()) {
507-
// Do a shoot-and-forget ping and schedule a new ping over 8 seconds after this ping has
508-
// finished.
509-
ApiFuture<ResultSet> future =
510-
executeQueryAsync(
544+
ReadWriteTransaction transaction = transactionRef.get();
545+
if (transaction != null && transaction.shouldPing()) {
546+
if (transaction.abortedLock.tryLock()) {
547+
boolean schedulePing = false;
548+
try {
549+
// Do a shoot-and-forget ping.
550+
// Note: executeQueryAsync automatically adds StatementResultCallback,
551+
// which calls maybeScheduleKeepAlivePing() upon completion.
552+
transaction.executeQueryAsync(
511553
CallType.SYNC,
512554
SELECT1_STATEMENT,
513555
AnalyzeMode.NONE,
514556
Options.tag(
515557
System.getProperty(
516558
"spanner.connection.keep_alive_query_tag",
517559
"connection.transaction-keep-alive")));
518-
future.addListener(
519-
ReadWriteTransaction.this::maybeScheduleKeepAlivePing, MoreExecutors.directExecutor());
560+
} catch (Throwable t) {
561+
schedulePing = true;
562+
} finally {
563+
transaction.abortedLock.unlock();
564+
}
565+
if (schedulePing) {
566+
transaction.maybeScheduleKeepAlivePing();
567+
}
568+
} else {
569+
// Transaction is currently busy (executing a statement or retrying).
570+
// Reschedule keep-alive ping for later since it is active.
571+
transaction.rescheduleKeepAlivePing();
572+
}
520573
}
521574
}
522575
}

java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ReadWriteTransactionTest.java

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@
2424
import static org.hamcrest.CoreMatchers.nullValue;
2525
import static org.hamcrest.MatcherAssert.assertThat;
2626
import static org.junit.Assert.assertEquals;
27+
import static org.junit.Assert.assertFalse;
2728
import static org.junit.Assert.assertNotNull;
29+
import static org.junit.Assert.assertNotSame;
2830
import static org.junit.Assert.assertNull;
31+
import static org.junit.Assert.assertSame;
32+
import static org.junit.Assert.assertTrue;
2933
import static org.junit.Assert.fail;
3034
import static org.mockito.Mockito.any;
3135
import static org.mockito.Mockito.doThrow;
@@ -67,6 +71,8 @@
6771
import java.math.BigDecimal;
6872
import java.util.Arrays;
6973
import java.util.Collections;
74+
import java.util.concurrent.CountDownLatch;
75+
import java.util.concurrent.ScheduledFuture;
7076
import org.junit.Test;
7177
import org.junit.runner.RunWith;
7278
import org.junit.runners.JUnit4;
@@ -158,12 +164,21 @@ private ReadWriteTransaction createSubject() {
158164
return createSubject(CommitBehavior.SUCCEED, false);
159165
}
160166

167+
private ReadWriteTransaction createSubject(boolean keepTransactionAlive) {
168+
return createSubject(CommitBehavior.SUCCEED, false, keepTransactionAlive);
169+
}
170+
161171
private ReadWriteTransaction createSubject(CommitBehavior commitBehavior) {
162-
return createSubject(commitBehavior, false);
172+
return createSubject(commitBehavior, false, false);
163173
}
164174

165175
private ReadWriteTransaction createSubject(
166176
final CommitBehavior commitBehavior, boolean withRetry) {
177+
return createSubject(commitBehavior, withRetry, false);
178+
}
179+
180+
private ReadWriteTransaction createSubject(
181+
final CommitBehavior commitBehavior, boolean withRetry, boolean keepTransactionAlive) {
167182
DatabaseClient client = mock(DatabaseClient.class);
168183
when(client.transactionManager())
169184
.thenAnswer(
@@ -179,6 +194,7 @@ private ReadWriteTransaction createSubject(
179194
});
180195
return ReadWriteTransaction.newBuilder()
181196
.setDatabaseClient(client)
197+
.setKeepTransactionAlive(keepTransactionAlive)
182198
.setRetryAbortsInternally(withRetry)
183199
.setIsolationLevel(IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED)
184200
.setSavepointSupport(SavepointSupport.FAIL_AFTER_ROLLBACK)
@@ -857,6 +873,172 @@ public void testGetCommitResponseAfterCommit() {
857873
assertNotNull(transaction.getCommitResponseOrNull());
858874
}
859875

876+
@Test
877+
public void testKeepAliveTaskRemovedFromQueueOnCancel() {
878+
ParsedStatement parsedStatement = mock(ParsedStatement.class);
879+
when(parsedStatement.getType()).thenReturn(StatementType.UPDATE);
880+
when(parsedStatement.isUpdate()).thenReturn(true);
881+
Statement statement = Statement.of("UPDATE FOO SET BAR=1 WHERE ID=2");
882+
when(parsedStatement.getStatement()).thenReturn(statement);
883+
884+
ReadWriteTransaction transaction = createSubject(/* keepTransactionAlive= */ true);
885+
get(transaction.executeUpdateAsync(CallType.SYNC, parsedStatement));
886+
887+
ScheduledFuture<?> future = waitForKeepAliveFuture(transaction);
888+
assertNotNull(future);
889+
assertTrue(ReadWriteTransaction.getKeepAliveService().getQueue().contains(future));
890+
891+
get(transaction.commitAsync(CallType.SYNC, NoopEndTransactionCallback.INSTANCE));
892+
assertFalse(ReadWriteTransaction.getKeepAliveService().getQueue().contains(future));
893+
}
894+
895+
@Test
896+
public void testKeepAliveWeakReference() {
897+
ReadWriteTransaction transaction = createSubject(/* keepTransactionAlive= */ true);
898+
ReadWriteTransaction.KeepAliveRunnable runnable =
899+
new ReadWriteTransaction.KeepAliveRunnable(transaction);
900+
901+
assertNotNull(runnable.transactionRef);
902+
assertSame(transaction, runnable.transactionRef.get());
903+
}
904+
905+
@Test
906+
public void testKeepAliveRescheduledWhenLockBusy() {
907+
ParsedStatement parsedStatement = mock(ParsedStatement.class);
908+
when(parsedStatement.getType()).thenReturn(StatementType.UPDATE);
909+
when(parsedStatement.isUpdate()).thenReturn(true);
910+
Statement statement = Statement.of("UPDATE FOO SET BAR=1 WHERE ID=2");
911+
when(parsedStatement.getStatement()).thenReturn(statement);
912+
913+
ReadWriteTransaction transaction = createSubject(/* keepTransactionAlive= */ true);
914+
get(transaction.executeUpdateAsync(CallType.SYNC, parsedStatement));
915+
916+
ScheduledFuture<?> future1 = waitForKeepAliveFuture(transaction);
917+
assertNotNull(future1);
918+
919+
CountDownLatch latch = new CountDownLatch(1);
920+
CountDownLatch lockAcquired = new CountDownLatch(1);
921+
Thread lockHoldingThread =
922+
new Thread(
923+
() -> {
924+
transaction.abortedLock.lock();
925+
try {
926+
lockAcquired.countDown();
927+
latch.await();
928+
} catch (InterruptedException e) {
929+
Thread.currentThread().interrupt();
930+
} finally {
931+
transaction.abortedLock.unlock();
932+
}
933+
});
934+
lockHoldingThread.start();
935+
try {
936+
lockAcquired.await();
937+
ReadWriteTransaction.KeepAliveRunnable runnable =
938+
new ReadWriteTransaction.KeepAliveRunnable(transaction);
939+
runnable.run();
940+
} catch (InterruptedException e) {
941+
Thread.currentThread().interrupt();
942+
fail("Test interrupted");
943+
} finally {
944+
latch.countDown();
945+
try {
946+
lockHoldingThread.join();
947+
} catch (InterruptedException e) {
948+
Thread.currentThread().interrupt();
949+
}
950+
}
951+
952+
ScheduledFuture<?> future2 = transaction.getKeepAliveFuture();
953+
assertNotNull(future2);
954+
assertNotSame(future1, future2);
955+
}
956+
957+
@Test
958+
public void testKeepAliveFutureNullifiedOnCancel() {
959+
ParsedStatement parsedStatement = mock(ParsedStatement.class);
960+
when(parsedStatement.getType()).thenReturn(StatementType.UPDATE);
961+
when(parsedStatement.isUpdate()).thenReturn(true);
962+
Statement statement = Statement.of("UPDATE FOO SET BAR=1 WHERE ID=2");
963+
when(parsedStatement.getStatement()).thenReturn(statement);
964+
965+
ReadWriteTransaction transaction = createSubject(/* keepTransactionAlive= */ true);
966+
get(transaction.executeUpdateAsync(CallType.SYNC, parsedStatement));
967+
968+
assertNotNull(waitForKeepAliveFuture(transaction));
969+
970+
get(transaction.commitAsync(CallType.SYNC, NoopEndTransactionCallback.INSTANCE));
971+
972+
assertNull(transaction.getKeepAliveFuture());
973+
}
974+
975+
private static ScheduledFuture<?> waitForKeepAliveFuture(ReadWriteTransaction transaction) {
976+
long deadline = System.currentTimeMillis() + 5000;
977+
while (System.currentTimeMillis() < deadline) {
978+
ScheduledFuture<?> future = transaction.getKeepAliveFuture();
979+
if (future != null) {
980+
return future;
981+
}
982+
try {
983+
Thread.sleep(1);
984+
} catch (InterruptedException e) {
985+
Thread.currentThread().interrupt();
986+
break;
987+
}
988+
}
989+
fail("Keep-alive future was not populated within 5 seconds");
990+
return null;
991+
}
992+
993+
@Test
994+
public void testKeepAliveRunnableHandlesSynchronousException() {
995+
DatabaseClient client = mock(DatabaseClient.class);
996+
when(client.transactionManager())
997+
.thenAnswer(
998+
invocation -> {
999+
TransactionContext txContext = mock(TransactionContext.class);
1000+
when(txContext.executeQuery(any(Statement.class)))
1001+
.thenThrow(new RuntimeException("Simulated synchronous execution error"));
1002+
return new SimpleTransactionManager(txContext, CommitBehavior.SUCCEED);
1003+
});
1004+
1005+
ReadWriteTransaction transaction =
1006+
ReadWriteTransaction.newBuilder()
1007+
.setDatabaseClient(client)
1008+
.setKeepTransactionAlive(true)
1009+
.setRetryAbortsInternally(false)
1010+
.setIsolationLevel(IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED)
1011+
.setSavepointSupport(SavepointSupport.FAIL_AFTER_ROLLBACK)
1012+
.setTransactionRetryListeners(Collections.emptyList())
1013+
.withStatementExecutor(new StatementExecutor())
1014+
.setSpan(Span.getInvalid())
1015+
.build();
1016+
1017+
ReadWriteTransaction.KeepAliveRunnable runnable =
1018+
new ReadWriteTransaction.KeepAliveRunnable(transaction);
1019+
1020+
runnable.run();
1021+
1022+
assertFalse(transaction.abortedLock.isLocked());
1023+
}
1024+
1025+
@Test
1026+
public void testKeepAliveNotScheduledIfTransactionClosed() {
1027+
ParsedStatement parsedStatement = mock(ParsedStatement.class);
1028+
when(parsedStatement.getType()).thenReturn(StatementType.UPDATE);
1029+
when(parsedStatement.isUpdate()).thenReturn(true);
1030+
Statement statement = Statement.of("UPDATE FOO SET BAR=1 WHERE ID=2");
1031+
when(parsedStatement.getStatement()).thenReturn(statement);
1032+
1033+
ReadWriteTransaction transaction = createSubject(/* keepTransactionAlive= */ true);
1034+
get(transaction.executeUpdateAsync(CallType.SYNC, parsedStatement));
1035+
get(transaction.commitAsync(CallType.SYNC, NoopEndTransactionCallback.INSTANCE));
1036+
1037+
transaction.maybeScheduleKeepAlivePing();
1038+
1039+
assertNull(transaction.getKeepAliveFuture());
1040+
}
1041+
8601042
private static StatusRuntimeException createAbortedExceptionWithMinimalRetry() {
8611043
Metadata.Key<RetryInfo> key = ProtoUtils.keyForProto(RetryInfo.getDefaultInstance());
8621044
Metadata trailers = new Metadata();

0 commit comments

Comments
 (0)