-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Arm request timeouts on an event loop #2313
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -63,6 +63,7 @@ | |
| import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultEnabledProtocols; | ||
| import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultExpiredCookieEvictionDelay; | ||
| import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFailedIpCooldownEnabled; | ||
| import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultUseEventLoopTimeouts; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This block is alphabetical, so it belongs just before defaultUseInsecureTrustManager, not between the two failedIpCooldown entries. The same split repeats in the field list, the constructor parameters and assignments, the Builder field and copy constructor, the build() call, AsyncHttpClientConfigDefaults, and ahc-default.properties, and the getter lands between isFailedIpCooldownEnabled and getFailedIpCooldownPeriod. The constructor is positional with a lot of adjacent booleans, so order is worth more here than tidiness. |
||
| import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFailedIpCooldownPeriod; | ||
| import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFilterInsecureCipherSuites; | ||
| import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFollowRedirect; | ||
|
|
@@ -138,6 +139,7 @@ public class DefaultAsyncHttpClientConfig implements AsyncHttpClientConfig { | |
| private final int maxRequestRetry; | ||
| private final LoadBalance loadBalance; | ||
| private final boolean failedIpCooldownEnabled; | ||
| private final boolean useEventLoopTimeouts; | ||
| private final Duration failedIpCooldownPeriod; | ||
| private final boolean disableUrlEncodingForBoundRequests; | ||
| private final boolean useLaxCookieEncoder; | ||
|
|
@@ -243,6 +245,7 @@ private DefaultAsyncHttpClientConfig(// http | |
| int maxRequestRetry, | ||
| LoadBalance loadBalance, | ||
| boolean failedIpCooldownEnabled, | ||
| boolean useEventLoopTimeouts, | ||
| Duration failedIpCooldownPeriod, | ||
| boolean disableUrlEncodingForBoundRequests, | ||
| boolean useLaxCookieEncoder, | ||
|
|
@@ -348,6 +351,7 @@ private DefaultAsyncHttpClientConfig(// http | |
| this.maxRequestRetry = maxRequestRetry; | ||
| this.loadBalance = loadBalance; | ||
| this.failedIpCooldownEnabled = failedIpCooldownEnabled; | ||
| this.useEventLoopTimeouts = useEventLoopTimeouts; | ||
| this.failedIpCooldownPeriod = failedIpCooldownPeriod; | ||
| this.disableUrlEncodingForBoundRequests = disableUrlEncodingForBoundRequests; | ||
| this.useLaxCookieEncoder = useLaxCookieEncoder; | ||
|
|
@@ -518,6 +522,11 @@ public boolean isFailedIpCooldownEnabled() { | |
| return failedIpCooldownEnabled; | ||
| } | ||
|
|
||
| @Override | ||
| public boolean isUseEventLoopTimeouts() { | ||
| return useEventLoopTimeouts; | ||
| } | ||
|
|
||
| @Override | ||
| public Duration getFailedIpCooldownPeriod() { | ||
| return failedIpCooldownPeriod; | ||
|
|
@@ -937,6 +946,7 @@ public static class Builder { | |
| private int maxRequestRetry = defaultMaxRequestRetry(); | ||
| private LoadBalance loadBalance = defaultLoadBalance(); | ||
| private boolean failedIpCooldownEnabled = defaultFailedIpCooldownEnabled(); | ||
| private boolean useEventLoopTimeouts = defaultUseEventLoopTimeouts(); | ||
| private Duration failedIpCooldownPeriod = defaultFailedIpCooldownPeriod(); | ||
| private boolean disableUrlEncodingForBoundRequests = defaultDisableUrlEncodingForBoundRequests(); | ||
| private boolean useLaxCookieEncoder = defaultUseLaxCookieEncoder(); | ||
|
|
@@ -1045,6 +1055,7 @@ public Builder(AsyncHttpClientConfig config) { | |
| maxRequestRetry = config.getMaxRequestRetry(); | ||
| loadBalance = config.getLoadBalance(); | ||
| failedIpCooldownEnabled = config.isFailedIpCooldownEnabled(); | ||
| useEventLoopTimeouts = config.isUseEventLoopTimeouts(); | ||
| failedIpCooldownPeriod = config.getFailedIpCooldownPeriod(); | ||
| disableUrlEncodingForBoundRequests = config.isDisableUrlEncodingForBoundRequests(); | ||
| useLaxCookieEncoder = config.isUseLaxCookieEncoder(); | ||
|
|
@@ -1244,6 +1255,17 @@ public Builder setFailedIpCooldownEnabled(boolean failedIpCooldownEnabled) { | |
| return this; | ||
| } | ||
|
|
||
| /** | ||
| * @param useEventLoopTimeouts whether to arm request and read timeouts on an event loop instead of on | ||
| * the client's timer; see {@link AsyncHttpClientConfig#isUseEventLoopTimeouts()} | ||
| * for the trade-off this makes | ||
| * @return this | ||
| */ | ||
| public Builder setUseEventLoopTimeouts(boolean useEventLoopTimeouts) { | ||
| this.useEventLoopTimeouts = useEventLoopTimeouts; | ||
| return this; | ||
| } | ||
|
|
||
| /** | ||
| * @param failedIpCooldownPeriod how long a failed IP is deprioritized before it is re-probed; | ||
| * {@code null} resets to the default. Must not be negative; use | ||
|
|
@@ -1751,6 +1773,7 @@ public DefaultAsyncHttpClientConfig build() { | |
| maxRequestRetry, | ||
| loadBalance, | ||
| failedIpCooldownEnabled, | ||
| useEventLoopTimeouts, | ||
| failedIpCooldownPeriod, | ||
| disableUrlEncodingForBoundRequests, | ||
| useLaxCookieEncoder, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -82,6 +82,7 @@ | |
| import org.asynchttpclient.resolver.RequestHostnameResolver; | ||
| import org.asynchttpclient.uri.Uri; | ||
| import org.asynchttpclient.ws.WebSocketUpgradeHandler; | ||
| import org.jetbrains.annotations.Nullable; | ||
|
|
||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
|
|
@@ -404,7 +405,7 @@ private <T> ListenableFuture<T> sendRequestWithOpenChannel(NettyResponseFuture<T | |
| SocketAddress channelRemoteAddress = channel.remoteAddress(); | ||
| if (channelRemoteAddress != null) { | ||
| // otherwise, bad luck, the channel was closed, see bellow | ||
| scheduleRequestTimeout(future, (InetSocketAddress) channelRemoteAddress); | ||
| scheduleRequestTimeout(future, (InetSocketAddress) channelRemoteAddress, channel); | ||
| } | ||
|
|
||
| future.setChannelState(ChannelState.POOLED); | ||
|
|
@@ -1080,12 +1081,39 @@ private static void configureTransferAdapter(AsyncHandler<?> handler, HttpReques | |
|
|
||
| private void scheduleRequestTimeout(NettyResponseFuture<?> nettyResponseFuture, | ||
| InetSocketAddress originalRemoteAddress) { | ||
| scheduleRequestTimeout(nettyResponseFuture, originalRemoteAddress, null); | ||
| } | ||
|
|
||
| /** | ||
| * @param channel the channel the exchange will run on when it is already known, so the timeout can be armed | ||
| * on the loop that owns it and expire on the thread that would have to close it. Null on the | ||
| * connect path: the timeout is armed before the channel exists, deliberately, so that it also | ||
| * bounds address resolution and the connect itself. | ||
| */ | ||
| private void scheduleRequestTimeout(NettyResponseFuture<?> nettyResponseFuture, | ||
| InetSocketAddress originalRemoteAddress, | ||
| @Nullable Channel channel) { | ||
| nettyResponseFuture.touch(); | ||
| TimeoutsHolder timeoutsHolder = new TimeoutsHolder(nettyTimer, nettyResponseFuture, this, config, | ||
| originalRemoteAddress); | ||
| TimeoutsHolder timeoutsHolder = new TimeoutsHolder(nettyTimer, timeoutExecutor(channel), nettyResponseFuture, | ||
| this, config, originalRemoteAddress); | ||
| nettyResponseFuture.setTimeoutsHolder(timeoutsHolder); | ||
| } | ||
|
|
||
| /** | ||
| * The event loop to arm an exchange's timeouts on, or null to leave them on the client's timer. Prefers the | ||
| * channel's own loop; without a channel any loop will do, since what the wheel costs is a single thread for | ||
| * the whole client and a tick the deadline is rounded up to, not the identity of the thread. | ||
| */ | ||
| private @Nullable EventExecutor timeoutExecutor(@Nullable Channel channel) { | ||
| if (!config.isUseEventLoopTimeouts()) { | ||
| return null; | ||
| } | ||
| if (channel != null) { | ||
| return channel.eventLoop(); | ||
| } | ||
| return channelManager.getEventLoopGroup().next(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is almost never the loop the channel ends up on. initAndRegister calls next() again, so for an N loop group the affinity the javadoc above claims holds about one time in N. Every completion then cancels a scheduled entry on a foreign loop, and that entry sits in its queue waking it at the original request timeout deadline long after the request finished. Read timeout re-arms cross loops too. NettyConnectListener.onSuccess already has both the channel and the holder, it sets the resolved address there, so re-homing at that point would make the claim true on the connect path as well.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Separately: calling next() only to pick a timeout thread advances the group's round robin counter, and that is the same counter that assigns channels to loops. With a power of two group and a fixed number of next() calls per request, registrations can settle onto a subset of the loops. Whether that helps or hurts depends on whether an address resolver group is configured, so it is action at a distance either way. Picking with ThreadLocalRandom over the group's executors, or re-homing once the channel exists, leaves the chooser alone. |
||
| } | ||
|
|
||
| private static void scheduleReadTimeout(NettyResponseFuture<?> nettyResponseFuture) { | ||
| TimeoutsHolder timeoutsHolder = nettyResponseFuture.getTimeoutsHolder(); | ||
| if (timeoutsHolder != null) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,31 +15,84 @@ | |
| */ | ||
| package org.asynchttpclient.netty.timeout; | ||
|
|
||
| import io.netty.util.Timeout; | ||
| import io.netty.util.TimerTask; | ||
| import org.asynchttpclient.netty.NettyResponseFuture; | ||
| import org.asynchttpclient.netty.request.NettyRequestSender; | ||
| import org.jetbrains.annotations.Nullable; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
|
|
||
| import java.net.InetSocketAddress; | ||
| import java.util.concurrent.Future; | ||
| import java.util.concurrent.TimeoutException; | ||
| import java.util.concurrent.atomic.AtomicBoolean; | ||
|
|
||
| public abstract class TimeoutTimerTask implements TimerTask { | ||
| /** | ||
| * Also a {@link Runnable} so the same task can be armed either on a {@link io.netty.util.Timer} or on an | ||
| * event loop, which schedules {@code Runnable}s. Neither subclass reads the {@link Timeout} handed to | ||
| * {@link TimerTask#run(Timeout)}, so the two entry points are interchangeable. | ||
| */ | ||
| public abstract class TimeoutTimerTask implements TimerTask, Runnable { | ||
|
|
||
| private static final Logger LOGGER = LoggerFactory.getLogger(TimeoutTimerTask.class); | ||
|
|
||
| protected final AtomicBoolean done = new AtomicBoolean(); | ||
| protected final NettyRequestSender requestSender; | ||
| final TimeoutsHolder timeoutsHolder; | ||
| volatile NettyResponseFuture<?> nettyResponseFuture; | ||
| /** | ||
| * The scheduled entry this task is armed on: an {@link Timeout} from a {@link io.netty.util.Timer}, or a | ||
| * {@link Future} from an event loop. Held here rather than in a wrapper so arming a timeout allocates | ||
| * nothing beyond what the scheduler itself needs. | ||
| */ | ||
| private volatile @Nullable Object armed; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Object plus instanceof means an unrecognized handle makes cancellation a silent no-op instead of a compile error. It works today only because Netty's ScheduledFuture happens to extend java.util.concurrent.Future, and Netty 5 already split that hierarchy once. If it ever moves, every cancel quietly stops working and each request leaks a scheduled entry until its deadline, with no signal. Two typed fields, or a Runnable canceller captured at arm time, costs the same allocation and keeps the compiler in the loop. |
||
|
|
||
| TimeoutTimerTask(NettyResponseFuture<?> nettyResponseFuture, NettyRequestSender requestSender, TimeoutsHolder timeoutsHolder) { | ||
| this.nettyResponseFuture = nettyResponseFuture; | ||
| this.requestSender = requestSender; | ||
| this.timeoutsHolder = timeoutsHolder; | ||
| } | ||
|
|
||
| @Override | ||
| public void run() { | ||
| try { | ||
| run(null); | ||
| } catch (Exception e) { | ||
| // TimerTask#run is declared to throw, and on this entry point the caller is an event loop, where an | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The subclass this defends against cannot exist. The only constructor is package private, so nothing outside this package can extend TimeoutTimerTask, and both subclasses are in it and neither throws. Which also means run(Timeout) could just drop its throws clause and run() could call it straight, no catch needed. |
||
| // escaping exception would be swallowed into Netty's own handling. Neither task here throws, so this | ||
| // only matters for a subclass outside the library. | ||
| LOGGER.warn("Timeout task failed", e); | ||
| } | ||
| } | ||
|
|
||
| void armedOn(Object handle) { | ||
| armed = handle; | ||
| } | ||
|
|
||
| /** | ||
| * Cancels the scheduled entry this task was armed on, if any. Never interrupts: on the event-loop path the | ||
| * task may be running on the very thread this is called from, and nothing in it answers interruption. | ||
| */ | ||
| void cancelArmed() { | ||
| Object handle = armed; | ||
| armed = null; | ||
| if (handle instanceof Timeout) { | ||
| ((Timeout) handle).cancel(); | ||
| } else if (handle instanceof Future) { | ||
| ((Future<?>) handle).cancel(false); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. arm() catches RejectedExecutionException, this doesn't. Netty's ScheduledFutureTask.cancel goes through removeScheduled, which for an off-loop caller lazyExecutes a removal task, and offerTask rejects once the loop is shut down. So after a client.close() any late future.cancel(true) or abort() throws out of ListenableFuture.cancel(), which never threw before. The same try/catch arm() already has would settle it. |
||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Whether this task has been claimed, either by firing or by {@link #clean()}. Stands in for the | ||
| * scheduler's own already-expired flag, which the two schedulers spell differently, and is if anything the | ||
| * more precise of the two: it flips when {@code run} is entered rather than when the entry is marked. | ||
| */ | ||
| boolean isClaimed() { | ||
| return done.get(); | ||
| } | ||
|
|
||
| void expire(String message, long time) { | ||
| LOGGER.debug("{} for {} after {} ms", message, nettyResponseFuture, time); | ||
| requestSender.abort(nettyResponseFuture.channel(), nettyResponseFuture, new TimeoutException(message)); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This rationale is now spelled out four times: here, the
TimeoutsHolderclass doc,timeoutExecutor's Javadoc, and the class doc onTimeoutTimerTask. Keeping this as the canonical explanation and linking to it from the other three says it once and leaves a single place to update.