io.micronaut.http.client.netty.Pool49 Maven / Gradle / Ivy
/*
* Copyright 2017-2022 original authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.http.client.netty;
import io.micronaut.core.annotation.Internal;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.execution.DelayedExecutionFlow;
import io.micronaut.core.execution.ExecutionFlow;
import io.micronaut.http.client.HttpClientConfiguration;
import io.micronaut.http.client.exceptions.HttpClientException;
import io.micronaut.http.netty.channel.loom.EventLoopVirtualThreadScheduler;
import io.micronaut.http.netty.channel.loom.PrivateLoomSupport;
import io.micronaut.scheduling.LoomSupport;
import io.netty.channel.EventLoop;
import io.netty.channel.SingleThreadIoEventLoop;
import io.netty.util.concurrent.EventExecutor;
import io.netty.util.internal.ThreadExecutorMap;
import org.slf4j.Logger;
import java.util.ArrayDeque;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.LongAdder;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* This class handles the concurrent aspects of pooling for {@link ConnectionManager}.
*/
@Internal
final class Pool49 implements Pool {
private final Listener listener;
private final Logger log;
private final HttpClientConfiguration.ConnectionPoolConfiguration connectionPoolConfiguration;
/**
* There is one pool for each event loop this client runs on.
*/
private final Map localPoolsByLoop;
/**
* Ordered version of {@link #localPoolsByLoop} for faster access.
*/
private final List localPools;
/**
* Number of pending requests. This is used to enforce
* {@link HttpClientConfiguration.ConnectionPoolConfiguration#getMaxPendingAcquires()}. If
* there is no limit, this field is {@code null} to save on atomic operations.
*/
@Nullable
private final LongAdder globalPending;
/**
* Connection statistics shared between all local pools, e.g. number of open HTTP/2
* connections. These are used to enforce most limits from the
* {@link #connectionPoolConfiguration}.
*/
private final AtomicReference globalStats = new AtomicReference<>(GlobalStats.EMPTY);
/**
* Pending requests that are not associated with a particular event loop / local pool. These
* requests can be picked up by any idle connection on any event loop.
*/
private final Queue globalPendingRequests = new LinkedBlockingQueue<>();
// for testing
@SuppressWarnings("checkstyle:DeclarationOrder")
Function, LocalPoolPair> pickPreferredPoolOverride;
Pool49(Listener listener, Logger log, HttpClientConfiguration.ConnectionPoolConfiguration connectionPoolConfiguration, Iterable extends EventExecutor> group) {
this.log = log;
this.connectionPoolConfiguration = connectionPoolConfiguration;
this.listener = listener;
this.localPoolsByLoop = new LinkedHashMap<>();
for (EventExecutor loop : group) {
localPoolsByLoop.put(loop, new LocalPoolPair(loop));
}
this.localPools = List.copyOf(localPoolsByLoop.values());
if (connectionPoolConfiguration.getMaxPendingAcquires() != Integer.MAX_VALUE) {
globalPending = new LongAdder();
} else {
globalPending = null;
}
}
/**
* Call {@link ResizerConnection#dispatch} with exception handling.
*
* @param connection The connection to run the request on
* @param toDispatch The request to run on the connection
*/
private void dispatchSafe(ResizerConnection connection, PendingRequest toDispatch) {
try {
connection.dispatch(toDispatch);
} catch (Exception e) {
if (!toDispatch.tryCompleteExceptionally(e)) {
// this is probably fine, log it anyway
log.debug("Failure during connection dispatch operation, but dispatch request was already complete.", e);
}
}
}
/**
* Called when an {@link Listener#openNewConnection(EventLoop)} operation fails asynchronously.
*
* @param eventLoop The event loop where the connection attempt was made
* @param error The optional error
*/
@Override
public void onNewConnectionFailure(@NonNull EventLoop eventLoop, @Nullable Throwable error) throws Exception {
// todo: implement a circuit breaker here? right now, we just fail one connection in the
// subclass implementation, but maybe we should do more.
LocalPoolPair poolPair = localPoolsByLoop.get(eventLoop);
assert poolPair != null;
poolPair.onNewConnectionFailure(listener.wrapError(error));
}
@Override
public Pool.PendingRequest createPendingRequest(@Nullable BlockHint blockHint) {
return new PendingRequest(blockHint);
}
@Override
public Pool.Http1PoolEntry createHttp1PoolEntry(@NonNull EventLoop eventLoop, @NonNull ResizerConnection connection) {
return new Http1PoolEntry(eventLoop, connection);
}
@Override
public Pool.Http2PoolEntry createHttp2PoolEntry(@NonNull EventLoop eventLoop, @NonNull ResizerConnection connection) {
return new Http2PoolEntry(eventLoop, connection);
}
/**
* Run a task for each connection. This is only best effort. Used for housekeeping.
*
* @param c The consumer to run for each open connection
*/
@Override
public void forEachConnection(Consumer c) {
for (LocalPoolPair localPool : localPools) {
localPool.http1.connections.forEach(e -> c.accept(e.connection));
localPool.http2.connections.forEach(e -> c.accept(e.connection));
}
}
/**
* Pick a preferred local pool for the current thread. This considers the
* {@link HttpClientConfiguration.ConnectionPoolConfiguration#getConnectionLocality()} to try
* to assign requests to connections on the same event loop as the request originates from.
* This can reduce latency.
*
* @return The preferred pool to run on, or {@code null} if there is no preference
* @throws HttpClientException For
* {@link io.micronaut.http.client.HttpClientConfiguration.ConnectionPoolConfiguration.ConnectionLocality#ENFORCED_ALWAYS},
* if the current thread does not belong to an event loop
*/
@Nullable
LocalPoolPair pickPreferredPool() throws HttpClientException {
if (pickPreferredPoolOverride != null) {
return pickPreferredPoolOverride.apply(localPools);
}
LocalPoolPair poolPair = null;
var configLocality = connectionPoolConfiguration.getConnectionLocality();
if (configLocality != HttpClientConfiguration.ConnectionPoolConfiguration.ConnectionLocality.IGNORE) {
EventExecutor currentExecutor = null;
if (PrivateLoomSupport.isSupported() &&
LoomSupport.isVirtual(Thread.currentThread()) &&
PrivateLoomSupport.getScheduler(Thread.currentThread()) instanceof EventLoopVirtualThreadScheduler el) {
currentExecutor = el.eventLoop();
}
if (currentExecutor == null) {
currentExecutor = ThreadExecutorMap.currentExecutor();
}
if (currentExecutor == null) {
for (LocalPoolPair pool : localPools) {
if (pool.loop.inEventLoop()) {
poolPair = pool;
break;
}
}
} else {
poolPair = localPoolsByLoop.get(currentExecutor);
}
if (poolPair == null && configLocality == HttpClientConfiguration.ConnectionPoolConfiguration.ConnectionLocality.ENFORCED_ALWAYS) {
throw new HttpClientException("Attempted to open a HTTP connection from thread " +
Thread.currentThread() + " which is not part of the client event loop group, but configured the pool in locality mode ENFORCED_ALWAYS, which disallows " +
"requesting from outside this group");
}
}
return poolPair;
}
/**
* First step of opening a new connection. If this step is run (and returns {@code true}), the
* other steps must also be run, but they can be delayed or on a different thread.
*
* The first step does the global housekeeping to determine whether we can open a new
* connection at all, and reserves a spot. It's not necessary to pick a particular event loop
* at this stage.
*
* @return {@code true} if a new connection may be opened without exceeding configured limits
* @see LocalPoolPair#openConnectionStep2()
* @see LocalPoolPair#openConnectionStep3()
*/
private boolean openConnectionStep1() {
while (true) {
GlobalStats oldStats = globalStats.get();
if (limitsHit(oldStats)) {
// just add to the pending request queue
return false;
}
if (!globalStats.compareAndSet(oldStats, oldStats.addPendingConnectionCount(1))) {
continue;
}
return true;
}
}
/**
* Check whether we have hit the connection limit.
*
* @param oldStats The current value of {@link #globalStats}
* @return {@code true} if the limits were hit and no more connections may be opened
*/
private boolean limitsHit(GlobalStats oldStats) {
return oldStats.pendingConnectionCount >= connectionPoolConfiguration.getMaxPendingConnections() ||
// limit the connection count to the protocol-specific settings, but only if that protocol was seen for this pool.
// if there's no connections at all, conservatively use the lesser of both limits
(oldStats.seenHttp1 && oldStats.http1ConnectionCount + oldStats.pendingConnectionCount >= connectionPoolConfiguration.getMaxConcurrentHttp1Connections()) ||
(oldStats.seenHttp2 && oldStats.http2ConnectionCount + oldStats.pendingConnectionCount >= connectionPoolConfiguration.getMaxConcurrentHttp2Connections()) ||
(!oldStats.seenHttp1 && !oldStats.seenHttp2 && (
oldStats.pendingConnectionCount >= connectionPoolConfiguration.getMaxConcurrentHttp1Connections() ||
oldStats.pendingConnectionCount >= connectionPoolConfiguration.getMaxConcurrentHttp2Connections()
));
}
/**
* Open a connection on any event loop, if necessary. This can be called e.g. when another
* connection closes.
*/
private void openGlobalConnectionIfNecessary() {
while (true) {
if (globalPendingRequests.isEmpty()) {
// best-effort check
break;
}
// try to open a connection for a request in the global queue
if (!openConnectionStep1()) {
return;
}
PendingRequest request = globalPendingRequests.poll();
LocalPoolPair pool;
if (request == null || request.preferredPool == null) {
pool = localPools.get(ThreadLocalRandom.current().nextInt(localPools.size()));
} else {
pool = request.preferredPool;
}
pool.loop.execute(() -> {
pool.openConnectionStep2();
if (request != null) {
request.destPool = pool;
pool.addLocalPendingRequest(request);
}
pool.openConnectionStep3();
});
if (request == null) {
break;
}
}
// open connections for any requests in local queues
if (!limitsHit(globalStats.get())) {
// randomize so that there is no bias to opening connections on the first event loop,
// when there's high contention
for (LocalPoolPair pool : RandomOffsetIterator.iterable(localPools)) {
if (pool.needPendingConnection) {
pool.loop.execute(pool::openLocalConnectionIfNecessary);
}
}
}
}
/**
* Iterator that cycles through a {@link List} from a random starting offset.
*
* @param The element type
*/
private static final class RandomOffsetIterator implements Iterator {
final List source;
final int start;
int i;
private RandomOffsetIterator(List source) {
this.source = source;
this.start = ThreadLocalRandom.current().nextInt(source.size());
this.i = start;
}
static Iterable iterable(List source) {
return () -> new RandomOffsetIterator<>(source);
}
@Override
public boolean hasNext() {
return i != -1;
}
@Override
public E next() {
int pos = i;
if (pos == -1) {
throw new NoSuchElementException();
}
int next = pos + 1;
if (next == source.size()) {
next = 0;
}
if (next == start) {
next = -1;
}
i = next;
return source.get(pos);
}
}
/**
* A local pool pair. It's local because it's associated with a fixed event loop, and most
* operations are restricted to that loop. It's a pair because there's actually two pools, one
* for HTTP/1 and one for HTTP/2 (also includes HTTP/3), since HTTP/2 can serve many requests
* over the same connection.
*/
final class LocalPoolPair {
final EventExecutor loop;
final LocalPool http1;
final LocalPool http2;
/**
* Number of connections that have been requested for this loop but not yet been
* established.
*/
int localPendingConnections = 0;
final AtomicBoolean dispatchPendingRequestsQueued = new AtomicBoolean(false);
/**
* Pending requests that will definitely run on this event loop.
*/
final Queue localPendingRequests = new ArrayDeque<>();
/**
* Volatile flag to check whether we need more connections to serve the pending requests of
* this loop. Basically {@code localPendingConnections < localPendingRequests.size()},
* except this is volatile so other threads can also read it.
*/
volatile boolean needPendingConnection = false;
LocalPoolPair(EventExecutor loop) {
this.loop = loop;
http1 = new LocalPool<>();
http2 = new LocalPool<>();
}
/**
* Notify us that a request has been queued in {@link #globalPendingRequests} and we may
* want to pick it up with one of our idle connections.
*/
void notifyGlobalPendingRequestQueued() {
if (!dispatchPendingRequestsQueued.compareAndSet(false, true)) {
return;
}
loop.execute(() -> {
dispatchPendingRequestsQueued.set(false);
dispatchPendingRequests();
});
}
/**
* Try to find an already available pool entry to serve a request.
*
* @return The pool entry
*/
@Nullable
PoolEntry findAvailablePoolEntry() {
assert loop.inEventLoop();
PoolEntry http2 = this.http2.peekAvailable();
if (http2 != null) {
return http2;
}
PoolEntry http1 = this.http1.peekAvailable();
if (http1 != null) {
return http1;
}
return null;
}
/**
* Enqueue a request to be picked up by a local connection.
*
* @param request The request
*/
private void addLocalPendingRequest(PendingRequest request) {
localPendingRequests.add(request);
needPendingConnection = true;
}
/**
* Assign any pending requests (local or global) to available connections.
*/
void dispatchPendingRequests() {
// local requests first
while (!localPendingRequests.isEmpty()) {
PoolEntry poolEntry = findAvailablePoolEntry();
if (poolEntry == null) {
return;
}
PendingRequest request = localPendingRequests.poll();
assert request != null;
request.dispatchTo(poolEntry);
}
needPendingConnection = false;
// then global requests
if (globalPendingRequests.isEmpty()) {
return;
}
while (true) {
PoolEntry poolEntry = findAvailablePoolEntry();
if (poolEntry == null) {
return;
}
PendingRequest request = globalPendingRequests.poll();
if (request == null) {
return;
}
request.dispatchTo(poolEntry);
}
}
/**
* Second step of opening a connection. Like {@link #openConnectionStep1()}, this is
* housekeeping, but this time it's specific to a particular loop.
*
* @see #openConnectionStep1()
* @see #openConnectionStep2()
*/
void openConnectionStep2() {
localPendingConnections++;
needPendingConnection = localPendingRequests.size() < localPendingConnections;
}
/**
* Final step of opening a connection. This actually triggers opening a connection. We want
* to enqueue any request before calling this method, in case it's successful
* immediately.
*
* @see #openConnectionStep1()
* @see #openConnectionStep2()
*/
void openConnectionStep3() {
try {
listener.openNewConnection((EventLoop) loop);
} catch (Exception e) {
onNewConnectionFailure(e);
}
}
/**
* Open a local connection if limits permit and we have unassigned requests.
*/
void openLocalConnectionIfNecessary() {
assert loop.inEventLoop();
while (localPendingRequests.size() > localPendingConnections) {
if (!openConnectionStep1()) {
break;
}
openConnectionStep2();
openConnectionStep3();
}
}
/**
* Called when there's a connection failure for this pool. Will notify one pending request,
* and undo housekeeping from {@link #openConnectionStep1()} and
* {@link #openConnectionStep2()}.
*
* @param error The error to send to the pending request
*/
void onNewConnectionFailure(Throwable error) {
assert loop.inEventLoop();
globalStats.updateAndGet(s -> s.addPendingConnectionCount(-1)); // TODO: is this called for websockets?
localPendingConnections--;
PendingRequest local = localPendingRequests.poll();
if (local != null) {
local.tryCompleteExceptionally(error);
} else {
PendingRequest global = globalPendingRequests.poll();
if (global != null) {
global.tryCompleteExceptionally(error);
} else {
log.error("Failed to connect to remote", error);
}
}
openLocalConnectionIfNecessary();
openGlobalConnectionIfNecessary();
}
@Override
public String toString() {
String s;
if (loop instanceof SingleThreadIoEventLoop l) {
s = l.threadProperties().name();
} else {
s = loop.toString();
}
return "Pool[" + s + "]";
}
}
/**
* A local, event loop specific pool. This wraps a list of open connections, and a list of
* connections that are available to serve requests.
*
* @param The connection type
*/
private final class LocalPool {
/**
* All open connections for this pool. Thread-safe for {@link #forEachConnection}.
*/
final Set connections = ConcurrentHashMap.newKeySet();
/**
* First available connection. Doubly linked list. This is volatile so that other threads
* can safely check whether we have an available connection or not.
*
* @see #lastAvailable
* @see PoolEntry#prevAvailable
* @see PoolEntry#nextAvailable
*/
volatile E firstAvailable;
/**
* Last available connection.
*
* @see #firstAvailable
* @see PoolEntry#prevAvailable
* @see PoolEntry#nextAvailable
*/
E lastAvailable;
LocalPool() {
}
/**
* Get an available connection if possible.
*
* @return The available connection
*/
@Nullable
PoolEntry peekAvailable() {
return firstAvailable;
}
/**
* Mark a connection as available.
*
* @param entry The connection pool entry
* @return {@code true} if this connection is newly available, {@code false} if it already
* was and there is no change.
*/
boolean addAvailable(E entry) {
E last = lastAvailable;
if (entry.nextAvailable != null || last == entry) {
return false;
}
if (last == null) {
assert firstAvailable == null;
firstAvailable = entry;
} else {
last.nextAvailable = entry;
}
entry.prevAvailable = last;
lastAvailable = entry;
return true;
}
/**
* Mark a connection as unavailable.
*
* @param entry The connection pool entry
* @return {@code true} if this connection was available, {@code false} if it already
* wasn't and there is no change.
*/
boolean removeAvailable(E entry) {
PoolEntry next = entry.nextAvailable;
PoolEntry prev = entry.prevAvailable;
if (next == null) {
if (prev == null) {
if (lastAvailable == entry) {
assert firstAvailable == entry;
lastAvailable = null;
firstAvailable = null;
return true;
} else {
return false;
}
} else {
entry.prevAvailable = null;
assert lastAvailable == entry;
//noinspection unchecked
lastAvailable = (E) prev;
prev.nextAvailable = null;
return true;
}
} else {
entry.nextAvailable = null;
if (prev == null) {
assert firstAvailable == entry;
//noinspection unchecked
firstAvailable = (E) next;
next.prevAvailable = null;
return true;
} else {
entry.prevAvailable = null;
next.prevAvailable = prev;
prev.nextAvailable = next;
return true;
}
}
}
}
/**
* Wrapper for a connection in this pool.
*/
private abstract sealed class PoolEntry {
private static final AtomicInteger NEXT_DEBUG_ID = new AtomicInteger(1);
/**
* The pool this connection is part of.
*/
final LocalPoolPair poolPair;
/**
* The connection implementation.
*/
final ResizerConnection connection;
/**
* Human-readable ID for logging. Only generated on demand.
*/
int debugId;
/**
* Reference for the availability list.
*
* @see LocalPool#firstAvailable
* @see LocalPool#lastAvailable
* @see #nextAvailable
*/
PoolEntry prevAvailable;
/**
* Reference for the availability list.
*
* @see LocalPool#firstAvailable
* @see LocalPool#lastAvailable
* @see #prevAvailable
*/
PoolEntry nextAvailable;
PoolEntry(EventLoop eventLoop, ResizerConnection connection) {
this.poolPair = localPoolsByLoop.get(eventLoop);
if (this.poolPair == null) {
throw new IllegalArgumentException("Event loop not part of given group");
}
this.connection = connection;
}
private synchronized int debugId() {
if (debugId == 0) {
debugId = NEXT_DEBUG_ID.getAndIncrement();
}
return debugId;
}
@Override
public String toString() {
return getClass().getSimpleName() + "[" + debugId() + ", pool=" + poolPair + "]";
}
final void checkInEventLoop() {
assert poolPair.loop.inEventLoop();
}
/**
* Called when the connection is opened. Undoes the housekeeping from
* {@link #openConnectionStep1()} and {@link LocalPoolPair#openConnectionStep2()}.
*/
final void onOpenConnection() {
checkInEventLoop();
poolPair.localPendingConnections--;
GlobalStats oldStats;
while (true) {
oldStats = globalStats.get();
GlobalStats newStats = oldStats.addPendingConnectionCount(-1);
if (this instanceof Http2PoolEntry) {
newStats = newStats.addHttp2ConnectionCount(1);
} else {
newStats = newStats.addHttp1ConnectionCount(1);
}
if (globalStats.weakCompareAndSetPlain(oldStats, newStats)) {
break;
}
}
// since we decreased the pending connection count, another pool may have an
// opportunity to open a connection.
// Deliberately DO NOT prefer opening a connection on the same pool, here. That could
// lead to one pool monopolizing all the connections.
openGlobalConnectionIfNecessary();
}
/**
* Called prior to {@link ResizerConnection#dispatch}. The purpose is to mark this
* connection as unavailable.
*
* @param request The request that will be dispatched
*/
abstract void preDispatch(PendingRequest request);
}
final class Http1PoolEntry extends PoolEntry implements Pool.Http1PoolEntry {
Http1PoolEntry(EventLoop eventLoop, ResizerConnection connection) {
super(eventLoop, connection);
}
@Override
public void onConnectionEstablished() {
checkInEventLoop();
if (poolPair.http1.connections.add(this)) {
markAvailable();
onOpenConnection();
}
}
@Override
public void onConnectionInactive() {
checkInEventLoop();
poolPair.http1.removeAvailable(this);
if (poolPair.http1.connections.remove(this)) {
globalStats.updateAndGet(s -> s.addHttp1ConnectionCount(-1));
openGlobalConnectionIfNecessary();
}
}
@Override
public void markAvailable() {
checkInEventLoop();
if (poolPair.http1.addAvailable(this)) {
if (log.isTraceEnabled()) {
log.trace("{} became available", this);
}
poolPair.dispatchPendingRequests();
}
}
@Override
public void markUnavailable() {
if (poolPair.http1.removeAvailable(this)) {
if (log.isTraceEnabled()) {
log.trace("{} became unavailable", this);
}
}
}
@Override
void preDispatch(PendingRequest request) {
checkInEventLoop();
if (!poolPair.http1.removeAvailable(this)) {
throw new IllegalStateException("Entry wasn't available " + poolPair.http1.firstAvailable + " " + poolPair.http1.lastAvailable + " " + this);
}
}
}
final class Http2PoolEntry extends PoolEntry implements Pool.Http2PoolEntry {
private int available = 0;
Http2PoolEntry(EventLoop eventLoop, ResizerConnection connection) {
super(eventLoop, connection);
}
@Override
public void onConnectionEstablished(int maxStreamCount) {
checkInEventLoop();
if (poolPair.http2.connections.add(this)) {
markAvailable0(maxStreamCount);
onOpenConnection();
}
}
@Override
public void onConnectionInactive() {
checkInEventLoop();
if (available > 0) {
available = 0;
poolPair.http2.removeAvailable(this);
}
if (poolPair.http2.connections.remove(this)) {
globalStats.updateAndGet(s -> s.addHttp2ConnectionCount(-1));
openGlobalConnectionIfNecessary();
}
}
@Override
public void markAvailable() {
markAvailable0(1);
}
private void markAvailable0(int n) {
checkInEventLoop();
if (log.isTraceEnabled()) {
log.trace("{} became available x{}", this, n);
}
boolean newlyAvailable = available == 0;
available += n;
if (newlyAvailable) {
poolPair.http2.addAvailable(this);
poolPair.dispatchPendingRequests();
}
}
@Override
public void markUnavailable() {
checkInEventLoop();
if (log.isTraceEnabled()) {
log.trace("{} became unavailable", this);
}
available = 0;
poolPair.http2.removeAvailable(this);
}
@Override
void preDispatch(PendingRequest request) {
checkInEventLoop();
assert available > 0;
available--;
if (available == 0) {
poolPair.http2.removeAvailable(this);
}
}
}
/**
* Statistics shared between all local pools so that configured connection limits can be met.
*
* @param http1ConnectionCount Number of HTTP/1 connections
* @param http2ConnectionCount Number of HTTP/2 connections
* @param pendingConnectionCount Number of pending connections
* @param seenHttp1 If {@code true}, we've seen an HTTP/1 connection through the
* lifetime of this pool, meaning we should follow configured
* HTTP/1 connection count limits
* @param seenHttp2 If {@code true}, we've seen an HTTP/2 connection through the
* lifetime of this pool, meaning we should follow configured
* HTTP/2 connection count limits
*/
private record GlobalStats(
int http1ConnectionCount,
int http2ConnectionCount,
int pendingConnectionCount,
boolean seenHttp1,
boolean seenHttp2
) {
static final GlobalStats EMPTY = new GlobalStats(0, 0, 0, false, false);
GlobalStats addHttp1ConnectionCount(int n) {
return new GlobalStats(http1ConnectionCount + n, http2ConnectionCount, pendingConnectionCount, true, seenHttp2);
}
GlobalStats addHttp2ConnectionCount(int n) {
return new GlobalStats(http1ConnectionCount, http2ConnectionCount + n, pendingConnectionCount, seenHttp1, true);
}
GlobalStats addPendingConnectionCount(int n) {
return new GlobalStats(http1ConnectionCount, http2ConnectionCount, pendingConnectionCount + n, seenHttp1, seenHttp2);
}
}
/**
* An HTTP request that is waiting for a connection to run on.
*/
final class PendingRequest extends AtomicBoolean implements Pool.PendingRequest {
private static final AtomicInteger NEXT_DEBUG_ID = new AtomicInteger(1);
/**
* Hint for which thread is blocked waiting for this connection.
*/
final @Nullable BlockHint blockHint;
/**
* {@link ExecutionFlow} that completes with the connection.
*/
private final DelayedExecutionFlow sink = DelayedExecutionFlow.create();
/**
* Pool that we prefer based on the caller.
*/
private final LocalPoolPair preferredPool;
/**
* Pools other than {@link #preferredPool} may pick up this connection if and only if this
* field is {@code true}.
*/
private final boolean permitStealing;
/**
* The pool this request is currently assigned to. May change throughout the lifetime of
* the request.
*/
private volatile LocalPoolPair destPool;
private int debugId;
PendingRequest(@Nullable BlockHint blockHint) {
this.blockHint = blockHint;
preferredPool = pickPreferredPool();
permitStealing = preferredPool == null ||
connectionPoolConfiguration.getConnectionLocality() == HttpClientConfiguration.ConnectionPoolConfiguration.ConnectionLocality.PREFERRED;
}
private synchronized int debugId() {
if (debugId == 0) {
debugId = NEXT_DEBUG_ID.getAndIncrement();
}
return debugId;
}
/**
* Flow that completes when this request is assigned to a connection.
*
* @return The flow
*/
@Override
public @NonNull ExecutionFlow flow() {
return sink;
}
/**
* Kick off dispatching this request to a connection. Note that this must be called exactly
* once.
*/
@Override
public void dispatch() {
if (globalPending != null && globalPending.sum() >= connectionPoolConfiguration.getMaxPendingAcquires()) {
tryCompleteExceptionally(new HttpClientException("Cannot acquire connection, exceeded max pending acquires configuration"));
return;
}
if (log.isTraceEnabled()) {
log.trace("{}: Starting dispatch, preferred pool {}", this, preferredPool);
}
if (globalPending != null) {
globalPending.increment();
}
redispatch();
}
/**
* Attempt to redispatch this connection. Unlike {@link #dispatch()}, can be called
* multiple times, because it doesn't increase {@link #globalPending}.
*/
@Override
public void redispatch() {
if (preferredPool == null) {
destPool = localPools.get(ThreadLocalRandom.current().nextInt(localPools.size()));
if (log.isTraceEnabled()) {
log.trace("{}: Scheduling dispatch on {}", this, destPool);
}
} else {
destPool = preferredPool;
}
if (destPool.loop.inEventLoop()) {
dispatchLocal();
} else {
destPool.loop.execute(this::dispatchLocal);
}
}
/**
* Dispatch this request to the current {@link #destPool}.
*/
private void dispatchLocal() {
assert destPool.loop.inEventLoop();
boolean traceEnabled = log.isTraceEnabled();
if (traceEnabled) {
log.trace("{}: Attempting dispatch on {}", this, destPool);
}
// is there a connection already? use it.
PoolEntry available = destPool.findAvailablePoolEntry();
if (available != null) {
dispatchTo(available);
return;
}
if (permitStealing) {
// does another pool have an available connection? Move to that pool.
for (LocalPoolPair pool : RandomOffsetIterator.iterable(localPools)) {
if (pool != destPool && (pool.http1.firstAvailable != null || pool.http2.firstAvailable != null)) {
destPool = pool;
pool.loop.execute(this::dispatchLocal);
return;
}
}
}
// need to open a new connection.
if (preferredPool != null && destPool != preferredPool) {
if (traceEnabled) {
log.trace("{}: Moving back to preferred pool to open a new connection", this);
}
// move back to preferred pool first
destPool = preferredPool;
destPool.loop.execute(this::dispatchLocal);
return;
}
if (blockHint != null && blockHint.blocks((EventLoop) destPool.loop)) {
tryCompleteExceptionally(BlockHint.createException());
return;
}
boolean open = openConnectionStep1();
if (open) {
destPool.openConnectionStep2();
}
if (open || !permitStealing) {
if (traceEnabled) {
log.trace("{}: Adding to local pending requests", this);
}
destPool.addLocalPendingRequest(this);
} else {
// Limits are hit, can't open a new connection. Move this request to
// globalPendingRequests as fallback.
if (traceEnabled) {
log.trace("{}: Adding to global pending requests", this);
}
destPool = null;
globalPendingRequests.add(this);
for (LocalPoolPair pool : localPools) {
pool.notifyGlobalPendingRequestQueued();
}
}
if (open) {
if (traceEnabled) {
log.trace("{}: Opening a new connection", this);
}
destPool.openConnectionStep3();
}
}
/**
* Assign this request to the given connection.
*
* @param entry The connection
*/
private void dispatchTo(PoolEntry entry) {
if (log.isTraceEnabled()) {
log.trace("{}: Dispatching to connection {}", this, entry);
}
if (destPool == null) {
// from global pending request queue
destPool = entry.poolPair;
} else {
assert destPool.loop.inEventLoop();
assert destPool == entry.poolPair;
}
BlockHint blockHint = this.blockHint;
if (blockHint != null && blockHint.blocks(entry.poolPair.loop)) {
tryCompleteExceptionally(BlockHint.createException());
return;
}
entry.preDispatch(this);
dispatchSafe(entry.connection, this);
}
/**
* The event loop this request will likely run on. This is only best effort.
*
* @return The event loop, or {@code null} if unknown
*/
@Override
public @Nullable EventExecutor likelyEventLoop() {
LocalPoolPair pool = destPool;
return pool == null ? null : pool.loop;
}
// DelayedExecutionFlow does not allow concurrent completes, so this is a simple guard
boolean tryCompleteExceptionally(Throwable t) {
if (compareAndSet(false, true)) {
if (globalPending != null) {
globalPending.decrement();
}
sink.completeExceptionally(t);
return true;
} else {
return false;
}
}
@Override
public boolean tryComplete(ConnectionManager.PoolHandle value) {
if (compareAndSet(false, true)) {
if (globalPending != null) {
globalPending.decrement();
}
if (sink.isCancelled()) {
return false;
}
sink.complete(value);
return true;
} else {
return false;
}
}
@Override
public String toString() {
return "PendingRequest[" + debugId() + "]";
}
}
}