Skip to content

Commit 7f28c34

Browse files
committed
Explicitly bound the thread pool in API calls communication back to appserver layer
1 parent e253eec commit 7f28c34

2 files changed

Lines changed: 52 additions & 18 deletions

File tree

runtime/runtime_impl_jetty121/src/main/java/com/google/apphosting/runtime/http/JdkHttpApiHostClient.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@
3232
import java.net.SocketTimeoutException;
3333
import java.net.URL;
3434
import java.util.concurrent.Executor;
35-
import java.util.concurrent.Executors;
35+
import java.util.concurrent.LinkedBlockingQueue;
3636
import java.util.concurrent.ThreadFactory;
37+
import java.util.concurrent.ThreadPoolExecutor;
38+
import java.util.concurrent.TimeUnit;
3739
import java.util.concurrent.atomic.AtomicInteger;
3840

3941
/**
@@ -66,7 +68,22 @@ static JdkHttpApiHostClient create(String url, Config config) {
6668
t.setDaemon(true);
6769
return t;
6870
};
69-
Executor executor = Executors.newCachedThreadPool(factory);
71+
/*
72+
* Thread Pool Configuration & Bug Analysis:
73+
*
74+
* Similar to the JettyHttpApiHostClient, we explicitly bound the thread pool.
75+
* We cap the threads at `maxConnectionsPerDestination` (which defaults to 100)
76+
* instead of a hardcoded 200 to prevent severe memory pressure (Thread Stack sizes)
77+
* on smaller AppEngine instance classes like F1 (256MB) or F2 (512MB).
78+
* An unbounded thread pool allows a failing RPC to rapidly spin up thousands
79+
* of threads under retry, which overwhelms the JVM and the internal Datastore
80+
* Appserver connection, forcing it to respond with masking INTERNAL_ERROR fallbacks.
81+
*/
82+
int maxThreads = config.maxConnectionsPerDestination().orElse(100);
83+
ThreadPoolExecutor executor =
84+
new ThreadPoolExecutor(
85+
maxThreads, maxThreads, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), factory);
86+
executor.allowCoreThreadTimeOut(true);
7087
return new JdkHttpApiHostClient(config, new URL(url), executor);
7188
} catch (MalformedURLException e) {
7289
throw new UncheckedIOException(e);

runtime/runtime_impl_jetty121/src/main/java/com/google/apphosting/runtime/http/JettyHttpApiHostClient.java

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,7 @@
3232
import java.nio.channels.ClosedSelectorException;
3333
import java.util.Arrays;
3434
import java.util.Map;
35-
import java.util.concurrent.Executors;
3635
import java.util.concurrent.RejectedExecutionException;
37-
import java.util.concurrent.ThreadFactory;
3836
import java.util.concurrent.TimeoutException;
3937
import java.util.concurrent.atomic.AtomicInteger;
4038
import org.eclipse.jetty.client.BytesRequestContent;
@@ -48,6 +46,7 @@
4846
import org.eclipse.jetty.http.HttpHeader;
4947
import org.eclipse.jetty.http.HttpMethod;
5048
import org.eclipse.jetty.io.EofException;
49+
import org.eclipse.jetty.util.thread.QueuedThreadPool;
5150
import org.eclipse.jetty.util.thread.ScheduledExecutorScheduler;
5251
import org.eclipse.jetty.util.thread.Scheduler;
5352

@@ -86,20 +85,38 @@ static JettyHttpApiHostClient create(String url, Config config) {
8685
boolean daemon = false;
8786
Scheduler scheduler =
8887
new ScheduledExecutorScheduler(schedulerName, daemon, myLoader, myThreadGroup);
89-
ThreadFactory factory =
90-
runnable -> {
91-
Thread t = new Thread(myThreadGroup, runnable);
92-
t.setName("JettyHttpApiHostClient-" + threadCount.incrementAndGet());
93-
t.setDaemon(true);
94-
return t;
95-
};
96-
// By default HttpClient will use a QueuedThreadPool with minThreads=8 and maxThreads=200.
97-
// 8 threads is probably too much for most apps, especially since asynchronous I/O means that
98-
// 8 concurrent API requests probably don't need that many threads. It's also not clear
99-
// what advantage we'd get from using a QueuedThreadPool with a smaller minThreads value, versus
100-
// just one of the standard java.util.concurrent pools. Here we have minThreads=1, maxThreads=∞,
101-
// and idleTime=60 seconds. maxThreads=200 and maxThreads=∞ are probably equivalent in practice.
102-
httpClient.setExecutor(Executors.newCachedThreadPool(factory));
88+
/*
89+
* Thread Pool Configuration & Bug Analysis:
90+
*
91+
* In previous versions of the runtime, an unbounded CachedThreadPool was used here:
92+
* `httpClient.setExecutor(Executors.newCachedThreadPool(factory));`
93+
*
94+
* Under high load (e.g., when a customer's custom retry logic aggressively retries failing
95+
* RPCs like `BeginTransaction`), an unbounded thread pool creates thousands of threads instantly.
96+
* This leads to a system collapse:
97+
* 1. JVM Overload: The Java container becomes severely memory and CPU constrained.
98+
* 2. Appserver Flooded: The avalanche of concurrent requests from the Java container floods the
99+
* C++ Appserver proxy.
100+
* 3. Triggering the C++ Bug Mask: Under massive load, the C++ Appserver's gRPC calls to the
101+
* Datastore fail with UNAVAILABLE or RESOURCE_EXHAUSTED errors.
102+
* 4. The Response: Because these aren't standard application errors, the C++ code
103+
* (DatastoreClientHelper::DoneImpl) masks them as `Error::INTERNAL_ERROR` and returns the
104+
* message "Internal Datastore Error" to the Java client to prevent leaking internal
105+
* infrastructure details.
106+
* 5. The Java client throws DatastoreFailureException, triggering the customer's loop again.
107+
*
108+
* To prevent this "retry storm", we explicitly use a bounded QueuedThreadPool.
109+
* We cap the threads at `maxConnectionsPerDestination` (which defaults to 100)
110+
* instead of a hardcoded 200 to prevent severe memory pressure (Thread Stack sizes)
111+
* on smaller AppEngine instance classes like F1 (256MB) or F2 (512MB).
112+
* If the system experiences a spike, Jetty will safely queue the outgoing RPCs, preventing the
113+
* JVM and the Appserver from being overwhelmed and eliminating the INTERNAL_ERROR fallback loop.
114+
*/
115+
int maxThreads = config.maxConnectionsPerDestination().orElse(100);
116+
QueuedThreadPool threadPool = new QueuedThreadPool(maxThreads, 10, 60000, null, myThreadGroup);
117+
threadPool.setName("JettyHttpApiHostClient");
118+
threadPool.setDaemon(true);
119+
httpClient.setExecutor(threadPool);
103120
httpClient.setScheduler(scheduler);
104121
config.maxConnectionsPerDestination().ifPresent(httpClient::setMaxConnectionsPerDestination);
105122
try {

0 commit comments

Comments
 (0)