Skip to content

Commit 097755a

Browse files
ludochgae-java-bot
authored andcommitted
Scope Datastore transaction stacks to ApiProxy environment lifecycle.
In container environments where worker threads are pooled and reused across incoming HTTP requests (such as Jetty 12 QueuedThreadPool and virtual threads), if a Datastore transaction terminates exceptionally or fails to roll back cleanly, the transaction instance can remain on the thread-local transaction stack (TransactionStackImpl.STACK). When BeginTransaction fails asynchronously, the future (SettableFuture) caches the DatastoreFailureException in memory. Any subsequent operation on that thread calling getCurrentTransaction() inherits the un-popped, poisoned transaction and immediately throws DatastoreFailureException ("Internal Datastore Error") locally without making remote network calls to the Appserver. This explains why backend RPC logs show no Datastore server errors during these error storms (b/494621464, b/537604854). To fix this cleanly while preserving per-thread isolation for multi-threaded requests (e.g. ThreadManager or parallel execution): - Updated TransactionStackImpl.StaticMember to track the current ApiProxy.Environment per thread via a ThreadLocal CURRENT_ENV. - Whenever a thread transitions to a different ApiProxy.Environment (or leaving/entering a request boundary), STACK.get().clear() automatically purges leftover transaction state from the thread-local stack. PiperOrigin-RevId: 963026907 Change-Id: Iefa6154f861036ba520b065f5ada3aa43aaab246
1 parent 8732f6e commit 097755a

2 files changed

Lines changed: 77 additions & 20 deletions

File tree

‎api/src/main/java/com/google/appengine/api/datastore/TransactionStackImpl.java‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616

1717
package com.google.appengine.api.datastore;
1818

19+
import com.google.apphosting.api.ApiProxy;
1920
import com.google.common.collect.Lists;
21+
import java.lang.ref.WeakReference;
2022
import java.util.ArrayList;
2123
import java.util.Collection;
2224
import java.util.HashMap;
@@ -177,6 +179,8 @@ interface ThreadLocalTransactionStack {
177179
TransactionDataMap get();
178180

179181
class StaticMember implements ThreadLocalTransactionStack {
182+
private static final ThreadLocal<WeakReference<ApiProxy.Environment>> CURRENT_ENV =
183+
new ThreadLocal<>();
180184
private static final ThreadLocal<TransactionDataMap> STACK =
181185
new ThreadLocal<TransactionDataMap>() {
182186
@Override
@@ -187,6 +191,13 @@ protected TransactionDataMap initialValue() {
187191

188192
@Override
189193
public TransactionDataMap get() {
194+
ApiProxy.Environment env = ApiProxy.getCurrentEnvironment();
195+
WeakReference<ApiProxy.Environment> weakEnv = CURRENT_ENV.get();
196+
ApiProxy.Environment prevEnv = weakEnv != null ? weakEnv.get() : null;
197+
if (env != prevEnv) {
198+
STACK.get().clear();
199+
CURRENT_ENV.set(env != null ? new WeakReference<>(env) : null);
200+
}
190201
return STACK.get();
191202
}
192203
}

‎api_dev/src/test/java/com/google/appengine/api/datastore/TransactionStackImplTest.java‎

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import static org.mockito.Mockito.mock;
2222
import static org.mockito.Mockito.when;
2323

24+
import com.google.apphosting.api.ApiProxy;
25+
import java.util.concurrent.atomic.AtomicReference;
2426
import org.junit.Test;
2527
import org.junit.runner.RunWith;
2628
import org.junit.runners.JUnit4;
@@ -79,34 +81,17 @@ public void testLifecycle_Nested_Txns() {
7981
assertThat(stack.getFutures(txn3)).isEmpty();
8082

8183
assertThat(stack.peek()).isSameInstanceAs(txn3);
82-
assertThat(stack.getFutures(txn1)).isEmpty();
83-
assertThat(stack.getFutures(txn2)).isEmpty();
84-
assertThat(stack.getFutures(txn3)).isEmpty();
8584

8685
assertThat(stack.pop()).isSameInstanceAs(txn3);
87-
assertThat(stack.getFutures(txn1)).isEmpty();
88-
assertThat(stack.getFutures(txn2)).isEmpty();
89-
assertEmptyFutures(txn3);
90-
86+
assertThat(stack.getFutures(txn3)).isEmpty();
9187
assertThat(stack.peek()).isSameInstanceAs(txn2);
92-
assertThat(stack.getFutures(txn1)).isEmpty();
93-
assertThat(stack.getFutures(txn2)).isEmpty();
94-
assertEmptyFutures(txn3);
9588

9689
assertThat(stack.pop()).isSameInstanceAs(txn2);
97-
assertThat(stack.getFutures(txn1)).isEmpty();
98-
assertEmptyFutures(txn2);
99-
assertEmptyFutures(txn3);
100-
90+
assertThat(stack.getFutures(txn2)).isEmpty();
10191
assertThat(stack.peek()).isSameInstanceAs(txn1);
102-
assertThat(stack.getFutures(txn1)).isEmpty();
103-
assertEmptyFutures(txn2);
104-
assertEmptyFutures(txn3);
10592

10693
assertThat(stack.pop()).isSameInstanceAs(txn1);
107-
assertEmptyFutures(txn1);
108-
assertEmptyFutures(txn2);
109-
assertEmptyFutures(txn3);
94+
assertThat(stack.getFutures(txn1)).isEmpty();
11095

11196
// once we've deregistered, everything else should fail
11297
assertThat(stack.peek(null)).isNull();
@@ -209,4 +194,65 @@ public void testPeekOnEmptyStack() {
209194
public void testPushNull() {
210195
assertThrows(NullPointerException.class, () -> stack.push(null));
211196
}
197+
198+
@Test
199+
public void testStaticMember_scopedToApiProxyEnvironment() {
200+
TransactionStackImpl.ThreadLocalTransactionStack staticMember =
201+
new TransactionStackImpl.ThreadLocalTransactionStack.StaticMember();
202+
ApiProxy.Environment env1 = mock(ApiProxy.Environment.class);
203+
204+
ApiProxy.setEnvironmentForCurrentThread(env1);
205+
try {
206+
TransactionStackImpl.TransactionDataMap map1 = staticMember.get();
207+
assertThat(map1).isNotNull();
208+
Transaction txn1 = mock(Transaction.class);
209+
when(txn1.getId()).thenReturn("123");
210+
map1.txns.push(txn1);
211+
212+
// Subsequent call in same environment returns same stack with txn1
213+
assertThat(staticMember.get().txns).containsExactly(txn1);
214+
215+
// Switching to a new environment (simulating request completion + thread reuse)
216+
ApiProxy.Environment env2 = mock(ApiProxy.Environment.class);
217+
ApiProxy.setEnvironmentForCurrentThread(env2);
218+
219+
// New environment triggers clear() on the thread-local stack
220+
assertThat(staticMember.get().txns).isEmpty();
221+
} finally {
222+
ApiProxy.clearEnvironmentForCurrentThread();
223+
}
224+
}
225+
226+
@Test
227+
public void testStaticMember_threadIsolation() throws Exception {
228+
TransactionStackImpl.ThreadLocalTransactionStack staticMember =
229+
new TransactionStackImpl.ThreadLocalTransactionStack.StaticMember();
230+
ApiProxy.Environment env = mock(ApiProxy.Environment.class);
231+
ApiProxy.setEnvironmentForCurrentThread(env);
232+
233+
try {
234+
TransactionStackImpl.TransactionDataMap mainThreadMap = staticMember.get();
235+
AtomicReference<TransactionStackImpl.TransactionDataMap> otherThreadMap =
236+
new AtomicReference<>();
237+
238+
Thread otherThread =
239+
new Thread(
240+
() -> {
241+
ApiProxy.setEnvironmentForCurrentThread(env);
242+
try {
243+
otherThreadMap.set(staticMember.get());
244+
} finally {
245+
ApiProxy.clearEnvironmentForCurrentThread();
246+
}
247+
});
248+
otherThread.start();
249+
otherThread.join();
250+
251+
// Even though both threads share the same ApiProxy.Environment, each thread has its own stack
252+
assertThat(otherThreadMap.get()).isNotNull();
253+
assertThat(otherThreadMap.get()).isNotSameInstanceAs(mainThreadMap);
254+
} finally {
255+
ApiProxy.clearEnvironmentForCurrentThread();
256+
}
257+
}
212258
}

0 commit comments

Comments
 (0)