userver: userver/utils/hedged_request.hpp Source File
Loading...
Searching...
No Matches
hedged_request.hpp
Go to the documentation of this file.
1#pragma once
2
3/// @file userver/utils/hedged_request.hpp
4/// @brief Classes and functions for performing hedged requests.
5///
6/// To perform hedged request you need to define RequestStrategy - a class
7/// similar to following ExampleStrategy:
8///
9/// class ExampleStrategy {
10/// public:
11/// /// Create request future.
12///
13/// /// Called at least once per hedged request. First time at the beginning
14/// /// of a hedged request and then every HedgingSettings::hedging_delay
15/// /// milliseconds if none of the previous requests are ready or
16/// /// ProcessReply returned non-nullopt result. If ProcessReply returned
17/// /// some interval of time, then additional request will be scheduled at
18/// /// this interval of time.
19/// /// @param attempt - increasing number for each try
20/// std::optional<RequestType> Create(std::size_t attempt);
21///
22/// /// ProcessReply is called when some request has finished. Method should
23/// /// evaluate request status and decide whether new attempt is required.
24/// /// If new attempt is required, then method must return delay for next
25/// /// attempt. If no other attempt is required, then method must return
26/// /// std::nullopt. It is expected that successful result will be stored
27/// /// somewhere internally and will be available with ExtractReply()
28/// /// method.
29/// std::optional<std::chrono::milliseconds> ProcessReply(RequestType&&);
30///
31/// std::optional<ReplyType> ExtractReply();
32///
33/// /// Method is called when system does not need request any more.
34/// /// For example, if one of the requests has been finished successfully,
35/// /// the system will finish all related subrequests.
36/// /// It is recommended to make this call as fast as possible in order
37/// /// not to block overall execution. For example, if you internally have
38/// /// some future, it is recommended to call TryCancel() here and wait
39/// /// for a cancellation in destructor, rather than call TryCancel() and
40/// /// immediately wait.
41/// void Finish(RequestType&&);
42/// };
43///
44/// Then call any of these functions:
45/// - HedgeRequest
46/// - HedgeRequestsBulk
47/// - HedgeRequestAsync
48/// - HedgeRequestsBulkAsync
49///
50
51#include <chrono>
52#include <compare>
53#include <functional>
54#include <optional>
55#include <queue>
56#include <type_traits>
57
58#include <userver/compiler/impl/lifetime.hpp>
59#include <userver/engine/awaitable.hpp>
60#include <userver/engine/task/cancel.hpp>
61#include <userver/engine/wait_any.hpp>
62#include <userver/utils/assert.hpp>
63#include <userver/utils/async.hpp>
64#include <userver/utils/datetime.hpp>
65
66USERVER_NAMESPACE_BEGIN
67
68namespace utils::hedging {
69
70/// Class define hedging settings
72 /// Maximum requests to do
73 std::size_t max_attempts{3};
74 /// Delay between attempts
75 std::chrono::milliseconds hedging_delay{7};
76 /// Max time to wait for all requests
77 std::chrono::milliseconds timeout_all{100};
78};
79
80template <typename RequestStrategy>
82 using RequestType =
83 typename std::invoke_result_t<decltype(&RequestStrategy::Create), RequestStrategy, int>::value_type;
84 using ReplyType =
85 typename std::invoke_result_t<decltype(&RequestStrategy::ExtractReply), RequestStrategy>::value_type;
86};
87
88namespace impl {
89
90using Clock = utils::datetime::SteadyClock;
91using TimePoint = Clock::time_point;
92
93enum class Action { kStartTry, kStop };
94
95struct PlanEntry {
96 PlanEntry(TimePoint timepoint, std::size_t request_index, std::size_t attempt_id, Action action)
97 : timepoint(timepoint),
98 request_index(request_index),
99 attempt_id(attempt_id),
100 action(action)
101 {}
102
103 auto operator<=>(const PlanEntry& other) const noexcept = default;
104
105 TimePoint timepoint;
106 std::size_t request_index{0};
107 std::size_t attempt_id{0};
108 Action action;
109};
110
111/// This wrapper allows us to cancel subrequests without removing elements
112/// from vector of requests
113template <typename RequestStrategy>
114struct SubrequestWrapper {
115 using RequestType = typename RequestTraits<RequestStrategy>::RequestType;
116
117 SubrequestWrapper() = default;
118 SubrequestWrapper(SubrequestWrapper&&) noexcept = default;
119 explicit SubrequestWrapper(std::optional<RequestType>&& request)
120 : request(std::move(request))
121 {}
122
123 /// Satisfies @ref engine::Awaitable, for use with @ref engine::WaitAnyContext and friends.
124 engine::AwaitableToken GetAwaitableToken() USERVER_IMPL_LIFETIME_BOUND {
125 if (!request) {
126 return engine::AwaitableToken{};
127 }
128 return request->GetAwaitableToken();
129 }
130
131 std::optional<RequestType> request;
132};
133
134struct RequestState {
135 std::vector<std::size_t> subrequest_indices;
136 std::size_t attempts_made = 0;
137 bool finished = false;
138};
139
140template <typename RequestStrategy>
141struct Context {
142 using RequestType = typename RequestTraits<RequestStrategy>::RequestType;
143 using ReplyType = typename RequestTraits<RequestStrategy>::ReplyType;
144
145 Context(std::vector<RequestStrategy> inputs, HedgingSettings settings)
146 : inputs_(std::move(inputs)),
147 settings_(std::move(settings))
148 {
149 const std::size_t size = this->inputs_.size();
150 request_states_.resize(size);
151 }
152 Context(Context&&) noexcept = default;
153
154 void Prepare(TimePoint start_time) {
155 const auto request_count = GetRequestsCount();
156 for (std::size_t request_id = 0; request_id < request_count; ++request_id) {
157 plan_.emplace(start_time, request_id, 0, Action::kStartTry);
158 }
159 plan_.emplace(start_time + settings_.timeout_all, 0, 0, Action::kStop);
160 subrequests_.reserve(settings_.max_attempts * request_count);
161 }
162
163 std::optional<TimePoint> NextEventTime() const {
164 if (plan_.empty()) {
165 return std::nullopt;
166 }
167 return plan_.top().timepoint;
168 }
169 std::optional<PlanEntry> PopPlan() {
170 if (plan_.empty()) {
171 return std::nullopt;
172 }
173 auto ret = plan_.top();
174 plan_.pop();
175 return ret;
176 }
177 bool IsStop() const { return stop_; }
178
179 void FinishAllSubrequests(std::size_t request_index) {
180 auto& request_state = request_states_[request_index];
181 request_state.finished = true;
182 const auto& subrequest_indices = request_state.subrequest_indices;
183 auto& strategy = inputs_[request_index];
184 for (auto i : subrequest_indices) {
185 auto& request = subrequests_[i].request;
186 if (request) {
187 strategy.Finish(std::move(*request));
188 request.reset();
189 }
190 }
191 }
192
193 const HedgingSettings& GetSettings() const { return settings_; }
194
195 size_t GetRequestsCount() const { return inputs_.size(); }
196
197 size_t GetRequestIdxBySubrequestIdx(size_t subrequest_idx) const {
198 return input_by_subrequests_.at(subrequest_idx);
199 }
200
201 RequestStrategy& GetStrategy(size_t index) { return inputs_[index]; }
202
203 auto& GetSubRequests() { return subrequests_; }
204
205 std::vector<std::optional<ReplyType>> ExtractAllReplies() {
206 std::vector<std::optional<ReplyType>> ret;
207 ret.reserve(GetRequestsCount());
208 for (auto&& strategy : inputs_) {
209 ret.emplace_back(strategy.ExtractReply());
210 }
211 return ret;
212 }
213
214 /// @name Reactions on events with WaitAny*
215 /// @{
216 /// Called on elapsed timeout of WaitAny when next event is Stop some
217 /// request
218 void OnActionStop() {
219 for (std::size_t i = 0; i < inputs_.size(); ++i) {
220 FinishAllSubrequests(i);
221 }
222 stop_ = true;
223 }
224
225 /// Called on elapsed timeout of WaitAny when next event is Start retry of
226 /// request with id equal @param request_index
227 void OnActionStartTry(std::size_t request_index, std::size_t attempt_id, TimePoint now) {
228 auto& request_state = request_states_[request_index];
229 if (request_state.finished) {
230 return;
231 }
232 auto& attempts_made = request_state.attempts_made;
233 // We could have already launched attempt with this number, for example if
234 // attempt number 2 failed with retryable code, we will add it to plan with
235 // number 3. This way, there are now two planned events, both with number=3
236 // - one from retry, one from hedging. We will execute the earliest one and
237 // skip the second one.
238 if (attempt_id < attempts_made) {
239 return;
240 }
241
242 if (attempts_made >= settings_.max_attempts) {
243 return;
244 }
245 auto& strategy = inputs_[request_index];
246 auto request_opt = strategy.Create(attempts_made);
247 if (!request_opt) {
248 request_state.finished = true;
249 // User do not want to make another request (maybe some retry budget is
250 // used)
251 return;
252 }
253 const auto idx = subrequests_.size();
254 subrequests_.emplace_back(std::move(request_opt));
255 request_state.subrequest_indices.push_back(idx);
256 input_by_subrequests_[idx] = request_index;
257 attempts_made++;
258 plan_.emplace(now + settings_.hedging_delay, request_index, attempts_made, Action::kStartTry);
259 }
260
261 /// Called on getting error in request with @param request_idx
262 void OnRetriableReply(std::size_t request_idx, std::chrono::milliseconds retry_delay, TimePoint now) {
263 const auto& request_state = request_states_[request_idx];
264 if (request_state.finished) {
265 return;
266 }
267 if (request_state.attempts_made >= settings_.max_attempts) {
268 return;
269 }
270
271 plan_.emplace(now + retry_delay, request_idx, request_state.attempts_made, Action::kStartTry);
272 }
273
274 void OnNonRetriableReply(std::size_t request_idx) { FinishAllSubrequests(request_idx); }
275 /// @}
276
277private:
278 /// user provided request strategies bulk
279 std::vector<RequestStrategy> inputs_;
280 HedgingSettings settings_;
281
282 /// Our plan of what we will do at what time
283 std::priority_queue<PlanEntry, std::vector<PlanEntry>, std::greater<>> plan_{};
284 std::vector<SubrequestWrapper<RequestStrategy>> subrequests_{};
285 /// Store index of input by subrequest index
286 std::unordered_map<std::size_t, std::size_t> input_by_subrequests_{};
287 std::vector<RequestState> request_states_{};
288 bool stop_{false};
289};
290
291} // namespace impl
292
293/// Future of hedged bulk request
294template <typename RequestStrategy>
296 using RequestType = typename RequestTraits<RequestStrategy>::RequestType;
297 using ReplyType = typename RequestTraits<RequestStrategy>::ReplyType;
298
299 HedgedRequestBulkFuture(HedgedRequestBulkFuture&&) noexcept = default;
300 ~HedgedRequestBulkFuture() { task_.SyncCancel(); }
301
302 /// @brief Wait for the request finish or for a caller task cancellation.
303 void Wait() { task_.Wait(); }
304
305 /// @copydoc engine::TaskWithResult::Get()
306 std::vector<std::optional<ReplyType>> Get() { return task_.Get(); }
307
308 /// Satisfies @ref engine::Awaitable, for use with @ref engine::WaitAnyContext and friends.
309 engine::AwaitableToken GetAwaitableToken() USERVER_IMPL_LIFETIME_BOUND { return task_.GetAwaitableToken(); }
310
311private:
312 template <typename TRequestStrategy>
313 friend auto HedgeRequestsBulkAsync(std::vector<TRequestStrategy> inputs, HedgingSettings settings);
314 using Task = engine::TaskWithResult<std::vector<std::optional<ReplyType>>>;
315 HedgedRequestBulkFuture(Task&& task)
316 : task_(std::move(task))
317 {}
318 Task task_;
319};
320
321/// Future of hedged request
322template <typename RequestStrategy>
324 using RequestType = typename RequestTraits<RequestStrategy>::RequestType;
325 using ReplyType = typename RequestTraits<RequestStrategy>::ReplyType;
326
327 HedgedRequestFuture(HedgedRequestFuture&&) noexcept = default;
328 ~HedgedRequestFuture() { task_.SyncCancel(); }
329
330 /// @brief Wait for the request finish or for a caller task cancellation.
331 void Wait() { task_.Wait(); }
332
333 /// @copydoc engine::TaskWithResult::Get()
334 std::optional<ReplyType> Get() { return task_.Get(); }
335
336 void IgnoreResult() {}
337
338 /// Satisfies @ref engine::Awaitable, for use with @ref engine::WaitAnyContext and friends.
339 engine::AwaitableToken GetAwaitableToken() USERVER_IMPL_LIFETIME_BOUND { return task_.GetAwaitableToken(); }
340
341private:
342 template <typename TRequestStrategy>
343 friend auto HedgeRequestAsync(TRequestStrategy input, HedgingSettings settings);
344 using Task = engine::TaskWithResult<std::optional<ReplyType>>;
345 HedgedRequestFuture(Task&& task)
346 : task_(std::move(task))
347 {}
348 Task task_;
349};
350
351/// @brief Synchronously perform bulk hedged requests described by `RequestStrategy` and
352/// return result of type `std::vector<std::optional<ResultType>>`.
353///
354/// Result contains replies for each element in `inputs` or `std::nullopt` in case
355/// of either timeouts or bad replies (`RequestStrategy::ProcessReply(RequestType&&)`
356/// returned `std::nullopt` and `RequestStrategy::ExtractReply()` returned
357/// `std::nullopt`).
358template <typename RequestStrategy>
359auto HedgeRequestsBulk(std::vector<RequestStrategy> inputs, HedgingSettings hedging_settings) {
360 {
361 using Action = impl::Action;
362 using Clock = impl::Clock;
363 auto context = impl::Context(std::move(inputs), std::move(hedging_settings));
364
365 auto& sub_requests = context.GetSubRequests();
366
367 auto wakeup_time = Clock::now();
368 context.Prepare(wakeup_time);
369
370 while (!context.IsStop()) {
371 auto wait_result = engine::WaitAnyUntil(wakeup_time, sub_requests);
372 if (!wait_result.has_value()) {
374 context.OnActionStop();
375 break;
376 }
377 /// timeout - need to process plan
378 auto plan_entry = context.PopPlan();
379 if (!plan_entry.has_value()) {
380 /// Timeout but we don't have planned actions any more
381 break;
382 }
383 const auto [timestamp, request_index, attempt_id, action] = *plan_entry;
384 switch (action) {
385 case Action::kStartTry:
386 context.OnActionStartTry(request_index, attempt_id, timestamp);
387 break;
388 case Action::kStop:
389 context.OnActionStop();
390 break;
391 }
392 auto next_wakeup_time = context.NextEventTime();
393 if (!next_wakeup_time.has_value()) {
394 break;
395 }
396 wakeup_time = *next_wakeup_time;
397 continue;
398 }
399 const auto result_idx = *wait_result;
400 UASSERT(result_idx < sub_requests.size());
401 const auto request_idx = context.GetRequestIdxBySubrequestIdx(result_idx);
402 auto& strategy = context.GetStrategy(request_idx);
403
404 auto& request = sub_requests[result_idx].request;
405 UASSERT_MSG(request, "Finished requests must not be empty");
406 auto reply = strategy.ProcessReply(std::move(*request));
407 if (reply.has_value()) {
408 /// Got reply but it's not OK and user wants to retry over
409 /// some delay
410 context.OnRetriableReply(request_idx, *reply, Clock::now());
411 /// No need to check. we just added one entry
412 wakeup_time = *context.NextEventTime();
413 } else {
414 context.OnNonRetriableReply(request_idx);
415 }
416 }
417 return context.ExtractAllReplies();
418 }
419}
420
421/// @brief Asynchronously perform bulk hedged requests described by `RequestStrategy` and
422/// return future which returns Result of type `std::vector<std::optional<ResultType>>`.
423///
424/// Result contains replies for each
425/// element in `inputs` or `std::nullopt` in case of either timeouts or bad
426/// replies (`RequestStrategy::ProcessReply(RequestType&&)` returned `std::nullopt`
427/// and `RequestStrategy::ExtractReply()` returned `std::nullopt`).
428template <typename RequestStrategy>
429auto HedgeRequestsBulkAsync(std::vector<RequestStrategy> inputs, HedgingSettings settings) {
430 return HedgedRequestBulkFuture<RequestStrategy>(utils::Async(
431 "hedged-bulk-request",
432 [inputs{std::move(inputs)}, settings{std::move(settings)}]() mutable {
433 return HedgeRequestsBulk(std::move(inputs), std::move(settings));
434 }
435 ));
436}
437
438/// Synchronously Perform hedged request described by RequestStrategy and return
439/// result or throw runtime_error. Exception can be thrown in case of timeout or
440/// if request was denied by strategy e.g. ProcessReply always returned
441/// std::nullopt or ExtractReply returned std::nullopt
442template <typename RequestStrategy>
443std::optional<typename RequestTraits<RequestStrategy>::ReplyType> HedgeRequest(
444 RequestStrategy input,
445 HedgingSettings settings
446) {
447 std::vector<RequestStrategy> inputs;
448 inputs.emplace_back(std::move(input));
449 auto bulk_ret = HedgeRequestsBulk(std::move(inputs), std::move(settings));
450 if (bulk_ret.size() != 1) {
451 return std::nullopt;
452 }
453 return bulk_ret[0];
454}
455
456/// Create future which perform hedged request described by RequestStrategy and
457/// return result or throw runtime_error. Exception can be thrown in case of
458/// timeout or if request was denied by strategy e.g. ProcessReply always
459/// returned std::nullopt or ExtractReply returned std::nullopt
460template <typename RequestStrategy>
461auto HedgeRequestAsync(RequestStrategy input, HedgingSettings settings) {
462 return HedgedRequestFuture<RequestStrategy>(utils::Async(
463 "hedged-request",
464 [input{std::move(input)}, settings{std::move(settings)}]() mutable {
465 return HedgeRequest(std::move(input), std::move(settings));
466 }
467 ));
468}
469
470} // namespace utils::hedging
471
472USERVER_NAMESPACE_END