Skip to content

Commit 8b5aaa6

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 8b5aaa6

2 files changed

Lines changed: 207 additions & 15 deletions

File tree

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

Lines changed: 61 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+
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,71 @@ 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+
static class KeepAliveRunnable implements Runnable {
534+
final WeakReference<ReadWriteTransaction> transactionRef;
535+
536+
KeepAliveRunnable(ReadWriteTransaction transaction) {
537+
this.transactionRef = new WeakReference<>(transaction);
538+
}
539+
504540
@Override
505541
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(
542+
ReadWriteTransaction transaction = transactionRef.get();
543+
if (transaction != null && transaction.shouldPing()) {
544+
if (transaction.abortedLock.tryLock()) {
545+
try {
546+
// Do a shoot-and-forget ping.
547+
// Note: executeQueryAsync automatically adds StatementResultCallback,
548+
// which calls maybeScheduleKeepAlivePing() upon completion.
549+
transaction.executeQueryAsync(
511550
CallType.SYNC,
512551
SELECT1_STATEMENT,
513552
AnalyzeMode.NONE,
514553
Options.tag(
515554
System.getProperty(
516555
"spanner.connection.keep_alive_query_tag",
517556
"connection.transaction-keep-alive")));
518-
future.addListener(
519-
ReadWriteTransaction.this::maybeScheduleKeepAlivePing, MoreExecutors.directExecutor());
557+
} catch (Throwable t) {
558+
transaction.maybeScheduleKeepAlivePing();
559+
} finally {
560+
transaction.abortedLock.unlock();
561+
}
562+
} else {
563+
// Transaction is currently busy (executing a statement or retrying).
564+
// Reschedule keep-alive ping for later since it is active.
565+
transaction.rescheduleKeepAlivePing();
566+
}
520567
}
521568
}
522569
}

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

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@
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;
2932
import static org.junit.Assert.fail;
3033
import static org.mockito.Mockito.any;
3134
import static org.mockito.Mockito.doThrow;
@@ -67,6 +70,7 @@
6770
import java.math.BigDecimal;
6871
import java.util.Arrays;
6972
import java.util.Collections;
73+
import java.util.concurrent.CountDownLatch;
7074
import org.junit.Test;
7175
import org.junit.runner.RunWith;
7276
import org.junit.runners.JUnit4;
@@ -158,12 +162,21 @@ private ReadWriteTransaction createSubject() {
158162
return createSubject(CommitBehavior.SUCCEED, false);
159163
}
160164

165+
private ReadWriteTransaction createSubject(boolean keepTransactionAlive) {
166+
return createSubject(CommitBehavior.SUCCEED, false, keepTransactionAlive);
167+
}
168+
161169
private ReadWriteTransaction createSubject(CommitBehavior commitBehavior) {
162-
return createSubject(commitBehavior, false);
170+
return createSubject(commitBehavior, false, false);
163171
}
164172

165173
private ReadWriteTransaction createSubject(
166174
final CommitBehavior commitBehavior, boolean withRetry) {
175+
return createSubject(commitBehavior, withRetry, false);
176+
}
177+
178+
private ReadWriteTransaction createSubject(
179+
final CommitBehavior commitBehavior, boolean withRetry, boolean keepTransactionAlive) {
167180
DatabaseClient client = mock(DatabaseClient.class);
168181
when(client.transactionManager())
169182
.thenAnswer(
@@ -179,6 +192,7 @@ private ReadWriteTransaction createSubject(
179192
});
180193
return ReadWriteTransaction.newBuilder()
181194
.setDatabaseClient(client)
195+
.setKeepTransactionAlive(keepTransactionAlive)
182196
.setRetryAbortsInternally(withRetry)
183197
.setIsolationLevel(IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED)
184198
.setSavepointSupport(SavepointSupport.FAIL_AFTER_ROLLBACK)
@@ -857,6 +871,137 @@ public void testGetCommitResponseAfterCommit() {
857871
assertNotNull(transaction.getCommitResponseOrNull());
858872
}
859873

874+
@Test
875+
public void testKeepAliveTaskRemovedFromQueueOnCancel() {
876+
ParsedStatement parsedStatement = mock(ParsedStatement.class);
877+
when(parsedStatement.getType()).thenReturn(StatementType.UPDATE);
878+
when(parsedStatement.isUpdate()).thenReturn(true);
879+
Statement statement = Statement.of("UPDATE FOO SET BAR=1 WHERE ID=2");
880+
when(parsedStatement.getStatement()).thenReturn(statement);
881+
882+
int initialQueueSize = ReadWriteTransaction.getKeepAliveService().getQueue().size();
883+
ReadWriteTransaction transaction = createSubject(/* keepTransactionAlive= */ true);
884+
get(transaction.executeUpdateAsync(CallType.SYNC, parsedStatement));
885+
886+
assertEquals(
887+
initialQueueSize + 1, ReadWriteTransaction.getKeepAliveService().getQueue().size());
888+
889+
get(transaction.commitAsync(CallType.SYNC, NoopEndTransactionCallback.INSTANCE));
890+
assertEquals(initialQueueSize, ReadWriteTransaction.getKeepAliveService().getQueue().size());
891+
}
892+
893+
@Test
894+
public void testKeepAliveWeakReference() {
895+
ReadWriteTransaction transaction = createSubject(/* keepTransactionAlive= */ true);
896+
ReadWriteTransaction.KeepAliveRunnable runnable =
897+
new ReadWriteTransaction.KeepAliveRunnable(transaction);
898+
899+
assertNotNull(runnable.transactionRef);
900+
assertSame(transaction, runnable.transactionRef.get());
901+
}
902+
903+
@Test
904+
public void testKeepAliveRescheduledWhenLockBusy() {
905+
ParsedStatement parsedStatement = mock(ParsedStatement.class);
906+
when(parsedStatement.getType()).thenReturn(StatementType.UPDATE);
907+
when(parsedStatement.isUpdate()).thenReturn(true);
908+
Statement statement = Statement.of("UPDATE FOO SET BAR=1 WHERE ID=2");
909+
when(parsedStatement.getStatement()).thenReturn(statement);
910+
911+
ReadWriteTransaction transaction = createSubject(/* keepTransactionAlive= */ true);
912+
get(transaction.executeUpdateAsync(CallType.SYNC, parsedStatement));
913+
914+
ScheduledFuture<?> future1 = transaction.getKeepAliveFuture();
915+
assertNotNull(future1);
916+
917+
CountDownLatch latch = new CountDownLatch(1);
918+
CountDownLatch lockAcquired = new CountDownLatch(1);
919+
Thread lockHoldingThread =
920+
new Thread(
921+
() -> {
922+
transaction.abortedLock.lock();
923+
try {
924+
lockAcquired.countDown();
925+
latch.await();
926+
} catch (InterruptedException e) {
927+
Thread.currentThread().interrupt();
928+
} finally {
929+
transaction.abortedLock.unlock();
930+
}
931+
});
932+
lockHoldingThread.start();
933+
try {
934+
lockAcquired.await();
935+
ReadWriteTransaction.KeepAliveRunnable runnable =
936+
new ReadWriteTransaction.KeepAliveRunnable(transaction);
937+
runnable.run();
938+
} catch (InterruptedException e) {
939+
Thread.currentThread().interrupt();
940+
fail("Test interrupted");
941+
} finally {
942+
latch.countDown();
943+
try {
944+
lockHoldingThread.join();
945+
} catch (InterruptedException e) {
946+
Thread.currentThread().interrupt();
947+
}
948+
}
949+
950+
ScheduledFuture<?> future2 = transaction.getKeepAliveFuture();
951+
assertNotNull(future2);
952+
assertNotSame(future1, future2);
953+
}
954+
955+
@Test
956+
public void testKeepAliveFutureNullifiedOnCancel() {
957+
ParsedStatement parsedStatement = mock(ParsedStatement.class);
958+
when(parsedStatement.getType()).thenReturn(StatementType.UPDATE);
959+
when(parsedStatement.isUpdate()).thenReturn(true);
960+
Statement statement = Statement.of("UPDATE FOO SET BAR=1 WHERE ID=2");
961+
when(parsedStatement.getStatement()).thenReturn(statement);
962+
963+
ReadWriteTransaction transaction = createSubject(/* keepTransactionAlive= */ true);
964+
get(transaction.executeUpdateAsync(CallType.SYNC, parsedStatement));
965+
966+
assertNotNull(transaction.getKeepAliveFuture());
967+
968+
get(transaction.commitAsync(CallType.SYNC, NoopEndTransactionCallback.INSTANCE));
969+
970+
assertNull(transaction.getKeepAliveFuture());
971+
}
972+
973+
@Test
974+
public void testKeepAliveRunnableHandlesSynchronousException() {
975+
DatabaseClient client = mock(DatabaseClient.class);
976+
when(client.transactionManager())
977+
.thenAnswer(
978+
invocation -> {
979+
TransactionContext txContext = mock(TransactionContext.class);
980+
when(txContext.executeQuery(any(Statement.class)))
981+
.thenThrow(new RuntimeException("Simulated synchronous execution error"));
982+
return new SimpleTransactionManager(txContext, CommitBehavior.SUCCEED);
983+
});
984+
985+
ReadWriteTransaction transaction =
986+
ReadWriteTransaction.newBuilder()
987+
.setDatabaseClient(client)
988+
.setKeepTransactionAlive(true)
989+
.setRetryAbortsInternally(false)
990+
.setIsolationLevel(IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED)
991+
.setSavepointSupport(SavepointSupport.FAIL_AFTER_ROLLBACK)
992+
.setTransactionRetryListeners(Collections.emptyList())
993+
.withStatementExecutor(new StatementExecutor())
994+
.setSpan(Span.getInvalid())
995+
.build();
996+
997+
ReadWriteTransaction.KeepAliveRunnable runnable =
998+
new ReadWriteTransaction.KeepAliveRunnable(transaction);
999+
1000+
runnable.run();
1001+
1002+
assertFalse(transaction.abortedLock.isLocked());
1003+
}
1004+
8601005
private static StatusRuntimeException createAbortedExceptionWithMinimalRetry() {
8611006
Metadata.Key<RetryInfo> key = ProtoUtils.keyForProto(RetryInfo.getDefaultInstance());
8621007
Metadata trailers = new Metadata();

0 commit comments

Comments
 (0)