Line data Source code
1 : // Copyright (C) 2021 The Android Open Source Project
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // http://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : package com.google.gerrit.server;
16 :
17 : import static java.util.Comparator.comparing;
18 : import static java.util.Objects.requireNonNull;
19 : import static java.util.concurrent.TimeUnit.MILLISECONDS;
20 : import static java.util.concurrent.TimeUnit.MINUTES;
21 : import static java.util.concurrent.TimeUnit.NANOSECONDS;
22 : import static java.util.stream.Collectors.toMap;
23 :
24 : import com.google.auto.value.AutoValue;
25 : import com.google.common.base.Strings;
26 : import com.google.common.collect.ImmutableList;
27 : import com.google.common.flogger.FluentLogger;
28 : import com.google.common.primitives.Longs;
29 : import com.google.gerrit.common.Nullable;
30 : import com.google.gerrit.server.cancellation.RequestStateProvider;
31 : import com.google.gerrit.server.config.ConfigUtil;
32 : import com.google.gerrit.server.config.GerritServerConfig;
33 : import com.google.gerrit.server.util.time.TimeUtil;
34 : import com.google.inject.assistedinject.Assisted;
35 : import com.google.inject.assistedinject.AssistedInject;
36 : import java.util.HashSet;
37 : import java.util.Map;
38 : import java.util.Optional;
39 : import java.util.Set;
40 : import java.util.concurrent.TimeUnit;
41 : import java.util.function.Function;
42 : import org.eclipse.jgit.lib.Config;
43 :
44 : /**
45 : * {@link RequestStateProvider} that checks whether a client provided deadline is exceeded.
46 : *
47 : * <p>Should be registered at most once per request.
48 : */
49 : public class DeadlineChecker implements RequestStateProvider {
50 104 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
51 :
52 104 : private static String SECTION_DEADLINE = "deadline";
53 :
54 : /**
55 : * Creates a formatter that formats a timeout as {@code <TIMEOUT_NAME>=<TIMEOUT><TIME_UNIT>}.
56 : *
57 : * <p>If the timeout is 1 minute or greater, minutes is used as a time unit. Otherwise
58 : * milliseconds is just as a time unit.
59 : *
60 : * @param timeoutName the name of the timeout
61 : */
62 : public static Function<Long, String> getTimeoutFormatter(String timeoutName) {
63 2 : requireNonNull(timeoutName, "timeoutName");
64 2 : return timeout -> {
65 2 : String formattedTimeout = MILLISECONDS.convert(timeout, NANOSECONDS) + "ms";
66 2 : long timeoutInMinutes = MINUTES.convert(timeout, NANOSECONDS);
67 2 : if (timeoutInMinutes > 0) {
68 0 : formattedTimeout = timeoutInMinutes + "m";
69 : }
70 2 : return String.format("%s=%s", timeoutName, formattedTimeout);
71 : };
72 : }
73 :
74 : public interface Factory {
75 : DeadlineChecker create(RequestInfo requestInfo, @Nullable String clientProvidedTimeoutValue)
76 : throws InvalidDeadlineException;
77 :
78 : DeadlineChecker create(
79 : long start, RequestInfo requestInfo, @Nullable String clientProvidedTimeoutValue)
80 : throws InvalidDeadlineException;
81 : }
82 :
83 : private final CancellationMetrics cancellationsMetrics;
84 :
85 : /** The start time of the request in nanoseconds. */
86 : private final long start;
87 :
88 : private final RequestInfo requestInfo;
89 : private final RequestStateProvider.Reason cancellationReason;
90 : private final String timeoutName;
91 :
92 : /**
93 : * Timeout in nanoseconds after which the request should be aborted.
94 : *
95 : * <p>{@code 0} means that no timeout should be applied.
96 : */
97 : private final long timeout;
98 :
99 : /**
100 : * The deadline in nanoseconds after which a request should be aborted.
101 : *
102 : * <p>deadline = start + timeout
103 : *
104 : * <p>{@link Optional#empty()} if no timeout was set.
105 : */
106 : private final Optional<Long> deadline;
107 :
108 : /**
109 : * Matching server side deadlines that have been configured as as advisory.
110 : *
111 : * <p>If any of these deadlines is exceeded the request is not be aborted. Instead the {@code
112 : * cancellation/advisory_deadline_count} metric is incremented and a log is written.
113 : */
114 : private final Map<String, ServerDeadline> advisoryDeadlines;
115 :
116 : /**
117 : * Creates a {@link DeadlineChecker}.
118 : *
119 : * <p>No deadline is enforced if the client provided deadline value is {@code null} or {@code 0}.
120 : *
121 : * @param requestInfo the request that was received from a user
122 : * @param clientProvidedTimeoutValue the timeout value that the client provided, must represent a
123 : * numerical time unit (e.g. "5m"), if no time unit is specified milliseconds are assumed, may
124 : * be {@code null}
125 : * @throws InvalidDeadlineException thrown if the client provided deadline value cannot be parsed,
126 : * e.g. because it uses a bad time unit
127 : */
128 : @AssistedInject
129 : DeadlineChecker(
130 : @GerritServerConfig Config serverConfig,
131 : CancellationMetrics cancellationsMetrics,
132 : @Assisted RequestInfo requestInfo,
133 : @Assisted @Nullable String clientProvidedTimeoutValue)
134 : throws InvalidDeadlineException {
135 32 : this(
136 : serverConfig,
137 : cancellationsMetrics,
138 32 : TimeUtil.nowNanos(),
139 : requestInfo,
140 : clientProvidedTimeoutValue);
141 32 : }
142 :
143 : /**
144 : * Creates a {@link DeadlineChecker}.
145 : *
146 : * <p>No deadline is enforced if the client provided deadline value is {@code null} or {@code 0}.
147 : *
148 : * @param start the start time of the request in nanoseconds
149 : * @param requestInfo the request that was received from a user
150 : * @param clientProvidedTimeoutValue the timeout value that the client provided, must represent a
151 : * numerical time unit (e.g. "5m"), if no time unit is specified milliseconds are assumed, may
152 : * be {@code null}
153 : * @throws InvalidDeadlineException thrown if the client provided deadline value cannot be parsed,
154 : * e.g. because it uses a bad time unit
155 : */
156 : @AssistedInject
157 : DeadlineChecker(
158 : @GerritServerConfig Config serverConfig,
159 : CancellationMetrics cancellationsMetrics,
160 : @Assisted long start,
161 : @Assisted RequestInfo requestInfo,
162 : @Assisted @Nullable String clientProvidedTimeoutValue)
163 104 : throws InvalidDeadlineException {
164 104 : this.cancellationsMetrics = cancellationsMetrics;
165 104 : this.start = start;
166 104 : this.requestInfo = requestInfo;
167 :
168 104 : ImmutableList<RequestConfig> deadlineConfigs =
169 104 : RequestConfig.parseConfigs(serverConfig, SECTION_DEADLINE);
170 104 : advisoryDeadlines = getAdvisoryDeadlines(deadlineConfigs, requestInfo);
171 104 : Optional<ServerDeadline> serverSideDeadline =
172 104 : getServerSideDeadline(deadlineConfigs, requestInfo);
173 104 : Optional<Long> clientedProvidedTimeout = parseTimeout(clientProvidedTimeoutValue);
174 104 : logDeadlines(serverSideDeadline, clientedProvidedTimeout);
175 :
176 104 : this.cancellationReason =
177 104 : clientedProvidedTimeout.isPresent()
178 2 : ? RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED
179 104 : : RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED;
180 104 : this.timeoutName =
181 : clientedProvidedTimeout
182 104 : .map(clientTimeout -> "client.timeout")
183 104 : .orElse(
184 : serverSideDeadline
185 104 : .map(serverDeadline -> serverDeadline.id() + ".timeout")
186 104 : .orElse("timeout"));
187 104 : this.timeout =
188 104 : clientedProvidedTimeout.orElse(serverSideDeadline.map(ServerDeadline::timeout).orElse(0L));
189 104 : this.deadline = timeout > 0 ? Optional.of(start + timeout) : Optional.empty();
190 104 : }
191 :
192 : private void logDeadlines(
193 : Optional<ServerDeadline> serverSideDeadline, Optional<Long> clientedProvidedTimeout) {
194 104 : if (serverSideDeadline.isPresent()) {
195 2 : if (clientedProvidedTimeout.isPresent()) {
196 2 : logger.atFine().log(
197 : "client provided deadline (timeout=%sms) overrides server deadline %s (timeout=%sms)",
198 2 : TimeUnit.MILLISECONDS.convert(clientedProvidedTimeout.get(), TimeUnit.NANOSECONDS),
199 2 : serverSideDeadline.get().id(),
200 2 : TimeUnit.MILLISECONDS.convert(
201 2 : serverSideDeadline.get().timeout(), TimeUnit.NANOSECONDS));
202 : } else {
203 2 : logger.atFine().log(
204 : "applying server deadline %s (timeout = %sms)",
205 2 : serverSideDeadline.get().id(),
206 2 : TimeUnit.MILLISECONDS.convert(
207 2 : serverSideDeadline.get().timeout(), TimeUnit.NANOSECONDS));
208 : }
209 104 : } else if (clientedProvidedTimeout.isPresent()) {
210 2 : logger.atFine().log(
211 : "applying client provided deadline (timeout = %sms)",
212 2 : TimeUnit.MILLISECONDS.convert(clientedProvidedTimeout.get(), TimeUnit.NANOSECONDS));
213 : }
214 104 : }
215 :
216 : private Optional<ServerDeadline> getServerSideDeadline(
217 : ImmutableList<RequestConfig> deadlineConfigs, RequestInfo requestInfo) {
218 104 : return deadlineConfigs.stream()
219 104 : .filter(deadlineConfig -> deadlineConfig.matches(requestInfo))
220 104 : .map(ServerDeadline::readFrom)
221 104 : .filter(ServerDeadline::hasTimeout)
222 104 : .filter(deadline -> !deadline.isAdvisory())
223 : // let the stricter deadline (the lower deadline) take precedence
224 104 : .sorted(comparing(ServerDeadline::timeout))
225 104 : .findFirst();
226 : }
227 :
228 : private Map<String, ServerDeadline> getAdvisoryDeadlines(
229 : ImmutableList<RequestConfig> deadlineConfigs, RequestInfo requestInfo) {
230 104 : return deadlineConfigs.stream()
231 104 : .filter(deadlineConfig -> deadlineConfig.matches(requestInfo))
232 104 : .map(ServerDeadline::readFrom)
233 104 : .filter(ServerDeadline::hasTimeout)
234 104 : .filter(ServerDeadline::isAdvisory)
235 104 : .collect(toMap(ServerDeadline::id, Function.identity()));
236 : }
237 :
238 : @Override
239 : public void checkIfCancelled(OnCancelled onCancelled) {
240 104 : long now = TimeUtil.nowNanos();
241 :
242 104 : Set<String> exceededAdvisoryDeadlines = new HashSet<>();
243 104 : advisoryDeadlines
244 104 : .values()
245 104 : .forEach(
246 : advisoryDeadline -> {
247 1 : if (now > start + advisoryDeadline.timeout()) {
248 1 : exceededAdvisoryDeadlines.add(advisoryDeadline.id());
249 1 : logger.atFine().log(
250 : "advisory deadline exceeded (%s)",
251 1 : getTimeoutFormatter(advisoryDeadline.id() + ".timeout")
252 1 : .apply(advisoryDeadline.timeout()));
253 1 : cancellationsMetrics.countAdvisoryDeadline(requestInfo, advisoryDeadline.id());
254 : }
255 1 : });
256 : // remove advisory deadlines which have already been reported as exceeded so that they don't get
257 : // reported again for this request
258 104 : exceededAdvisoryDeadlines.forEach(advisoryDeadlines::remove);
259 :
260 104 : if (deadline.isPresent() && now > deadline.get()) {
261 0 : onCancelled.onCancel(cancellationReason, getTimeoutFormatter(timeoutName).apply(timeout));
262 : }
263 104 : }
264 :
265 : /**
266 : * Parses the given timeout value.
267 : *
268 : * @param timeoutValue the timeout that should be parsed, must represent a numerical time unit
269 : * (e.g. "5m"), if no time unit is specified minutes are assumed, may be {@code null}
270 : * @return the parsed timeout in nanoseconds, {@code 0} if no timeout should be applied
271 : * @throws InvalidDeadlineException thrown if the provided deadline value cannot be parsed, e.g.
272 : * because it uses a bad time unit
273 : */
274 : private static Optional<Long> parseTimeout(@Nullable String timeoutValue)
275 : throws InvalidDeadlineException {
276 104 : if (Strings.isNullOrEmpty(timeoutValue)) {
277 104 : return Optional.empty();
278 : }
279 :
280 2 : if ("0".equals(timeoutValue)) {
281 2 : return Optional.of(0L);
282 : }
283 :
284 : // If no time unit was specified, assume milliseconds.
285 2 : if (Longs.tryParse(timeoutValue) != null) {
286 2 : throw new InvalidDeadlineException(String.format("Missing time unit: %s", timeoutValue));
287 : }
288 :
289 : try {
290 2 : long parsedTimeout =
291 2 : ConfigUtil.getTimeUnit(timeoutValue, /* defaultValue= */ -1, TimeUnit.NANOSECONDS);
292 2 : if (parsedTimeout == -1) {
293 2 : throw new InvalidDeadlineException(String.format("Invalid value: %s", timeoutValue));
294 : }
295 2 : return Optional.of(parsedTimeout);
296 2 : } catch (IllegalArgumentException e) {
297 2 : throw new InvalidDeadlineException(e.getMessage(), e);
298 : }
299 : }
300 :
301 : @AutoValue
302 2 : abstract static class ServerDeadline {
303 : abstract String id();
304 :
305 : abstract long timeout();
306 :
307 : abstract boolean isAdvisory();
308 :
309 : boolean hasTimeout() {
310 2 : return timeout() > 0;
311 : }
312 :
313 : static ServerDeadline readFrom(RequestConfig requestConfig) {
314 2 : String timeoutValue =
315 2 : requestConfig.cfg().getString(requestConfig.section(), requestConfig.id(), "timeout");
316 2 : boolean isAdvisory =
317 : requestConfig
318 2 : .cfg()
319 2 : .getBoolean(
320 2 : requestConfig.section(),
321 2 : requestConfig.id(),
322 : "isAdvisory",
323 : /* defaultValue= */ false);
324 : try {
325 2 : Optional<Long> timeout = parseTimeout(timeoutValue);
326 2 : return new AutoValue_DeadlineChecker_ServerDeadline(
327 2 : requestConfig.id(), timeout.orElse(0L), isAdvisory);
328 1 : } catch (InvalidDeadlineException e) {
329 1 : logger.atWarning().log(
330 : "Ignoring invalid deadline configuration %s.%s.timeout: %s",
331 1 : requestConfig.section(), requestConfig.id(), e.getMessage());
332 1 : return new AutoValue_DeadlineChecker_ServerDeadline(requestConfig.id(), 0, isAdvisory);
333 : }
334 : }
335 : }
336 : }
|