Skip to content

Commit e537ee3

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 e537ee3

2 files changed

Lines changed: 214 additions & 15 deletions

File tree

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

Lines changed: 66 additions & 14 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;
@@ -475,7 +487,7 @@ private void maybeScheduleKeepAlivePing() {
475487
if (keepAliveFuture == null || keepAliveFuture.isDone()) {
476488
keepAliveFuture =
477489
KEEP_ALIVE_SERVICE.schedule(
478-
new KeepAliveRunnable(),
490+
new KeepAliveRunnable(this),
479491
keepAliveIntervalMillis > 0
480492
? keepAliveIntervalMillis
481493
: DEFAULT_KEEP_ALIVE_INTERVAL_MILLIS,
@@ -487,36 +499,76 @@ private void maybeScheduleKeepAlivePing() {
487499
}
488500
}
489501

502+
@VisibleForTesting
503+
ScheduledFuture<?> getKeepAliveFuture() {
504+
return keepAliveFuture;
505+
}
506+
490507
private void cancelScheduledKeepAlivePing() {
491508
if (keepAliveLock != null) {
492509
keepAliveLock.lock();
493510
try {
494511
if (keepAliveFuture != null) {
495512
keepAliveFuture.cancel(false);
513+
keepAliveFuture = null;
496514
}
497515
} finally {
498516
keepAliveLock.unlock();
499517
}
500518
}
501519
}
502520

503-
private class KeepAliveRunnable implements Runnable {
521+
private void rescheduleKeepAlivePing() {
522+
if (keepAliveLock != null) {
523+
keepAliveLock.lock();
524+
try {
525+
keepAliveFuture = null;
526+
maybeScheduleKeepAlivePing();
527+
} finally {
528+
keepAliveLock.unlock();
529+
}
530+
}
531+
}
532+
533+
@VisibleForTesting
534+
static class KeepAliveRunnable implements Runnable {
535+
final WeakReference<ReadWriteTransaction> transactionRef;
536+
537+
KeepAliveRunnable(ReadWriteTransaction transaction) {
538+
this.transactionRef = new WeakReference<>(transaction);
539+
}
540+
504541
@Override
505542
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(
543+
ReadWriteTransaction transaction = transactionRef.get();
544+
if (transaction != null && transaction.shouldPing()) {
545+
if (transaction.abortedLock.tryLock()) {
546+
boolean schedulePing = false;
547+
try {
548+
// Do a shoot-and-forget ping.
549+
// Note: executeQueryAsync automatically adds StatementResultCallback,
550+
// which calls maybeScheduleKeepAlivePing() upon completion.
551+
transaction.executeQueryAsync(
511552
CallType.SYNC,
512553
SELECT1_STATEMENT,
513554
AnalyzeMode.NONE,
514555
Options.tag(
515556
System.getProperty(
516557
"spanner.connection.keep_alive_query_tag",
517558
"connection.transaction-keep-alive")));
518-
future.addListener(
519-
ReadWriteTransaction.this::maybeScheduleKeepAlivePing, MoreExecutors.directExecutor());
559+
} catch (Throwable t) {
560+
schedulePing = true;
561+
} finally {
562+
transaction.abortedLock.unlock();
563+
}
564+
if (schedulePing) {
565+
transaction.maybeScheduleKeepAlivePing();
566+
}
567+
} else {
568+
// Transaction is currently busy (executing a statement or retrying).
569+
// Reschedule keep-alive ping for later since it is active.
570+
transaction.rescheduleKeepAlivePing();
571+
}
520572
}
521573
}
522574
}

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

Lines changed: 148 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,137 @@ 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 = transaction.getKeepAliveFuture();
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 = transaction.getKeepAliveFuture();
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(transaction.getKeepAliveFuture());
969+
970+
get(transaction.commitAsync(CallType.SYNC, NoopEndTransactionCallback.INSTANCE));
971+
972+
assertNull(transaction.getKeepAliveFuture());
973+
}
974+
975+
@Test
976+
public void testKeepAliveRunnableHandlesSynchronousException() {
977+
DatabaseClient client = mock(DatabaseClient.class);
978+
when(client.transactionManager())
979+
.thenAnswer(
980+
invocation -> {
981+
TransactionContext txContext = mock(TransactionContext.class);
982+
when(txContext.executeQuery(any(Statement.class)))
983+
.thenThrow(new RuntimeException("Simulated synchronous execution error"));
984+
return new SimpleTransactionManager(txContext, CommitBehavior.SUCCEED);
985+
});
986+
987+
ReadWriteTransaction transaction =
988+
ReadWriteTransaction.newBuilder()
989+
.setDatabaseClient(client)
990+
.setKeepTransactionAlive(true)
991+
.setRetryAbortsInternally(false)
992+
.setIsolationLevel(IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED)
993+
.setSavepointSupport(SavepointSupport.FAIL_AFTER_ROLLBACK)
994+
.setTransactionRetryListeners(Collections.emptyList())
995+
.withStatementExecutor(new StatementExecutor())
996+
.setSpan(Span.getInvalid())
997+
.build();
998+
999+
ReadWriteTransaction.KeepAliveRunnable runnable =
1000+
new ReadWriteTransaction.KeepAliveRunnable(transaction);
1001+
1002+
runnable.run();
1003+
1004+
assertFalse(transaction.abortedLock.isLocked());
1005+
}
1006+
8601007
private static StatusRuntimeException createAbortedExceptionWithMinimalRetry() {
8611008
Metadata.Key<RetryInfo> key = ProtoUtils.keyForProto(RetryInfo.getDefaultInstance());
8621009
Metadata trailers = new Metadata();

0 commit comments

Comments
 (0)