Skip to content

Commit 66f7d18

Browse files
committed
fix(storage): address review comments on read hedging and thread pool
1 parent c0e1436 commit 66f7d18

5 files changed

Lines changed: 184 additions & 14 deletions

File tree

google/cloud/storage/internal/hedged_object_read_source.cc

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ void RunAttempt(std::shared_ptr<RaceState> const& state,
4545
HedgedObjectReadSource::ChildFactory const& factory,
4646
std::size_t n, bool resolve_on_open_error,
4747
std::shared_ptr<HedgingThreadPool> release_slot) {
48+
// Releases the acquired hedge concurrency slot upon function exit across
49+
// all code paths (early return on open/allocation error, race winner, or
50+
// race loser). For primary attempts, release_slot is nullptr.
4851
struct SlotGuard {
4952
std::shared_ptr<HedgingThreadPool> pool;
5053
~SlotGuard() {
@@ -113,7 +116,9 @@ StatusOr<HttpResponse> HedgedObjectReadSource::Close() {
113116

114117
StatusOr<ReadSourceResult> HedgedObjectReadSource::Read(char* buf,
115118
std::size_t n) {
116-
if (is_closed_) return ReadSourceResult{};
119+
if (is_closed_) {
120+
return ReadSourceResult{0, HttpResponse{HttpStatusCode::kOk, {}, {}}};
121+
}
117122

118123
// Only the stream open is hedged. Once a child has won the race all
119124
// subsequent reads continue on it, at its current offset, without any
@@ -142,9 +147,20 @@ StatusOr<ReadSourceResult> HedgedObjectReadSource::Read(char* buf,
142147
// complete either way.
143148
if (!read_pool_->Enqueue(primary)) primary();
144149

145-
for (int hedges_dispatched = 0; hedges_dispatched != max_hedges_;) {
150+
for (int hedges_dispatched = 0; hedges_dispatched < max_hedges_;) {
146151
if (future.wait_for(delay_) != std::future_status::timeout) break;
147-
if (!hedge_pool_->TryAcquireHedgeToken()) continue;
152+
if (!hedge_pool_->TryAcquireHedgeToken()) {
153+
// When delay_ is 0ms (or token acquisition fails), back off briefly on
154+
// the future instead of busy-spinning if tokens or concurrency slots are
155+
// temporarily exhausted.
156+
if (delay_ == std::chrono::milliseconds::zero()) {
157+
if (future.wait_for(std::chrono::milliseconds(10)) !=
158+
std::future_status::timeout) {
159+
break;
160+
}
161+
}
162+
continue;
163+
}
148164
auto hedge = [state, factory = child_factory_, n, pool = hedge_pool_] {
149165
RunAttempt(state, factory, n, /*resolve_on_open_error=*/false, pool);
150166
};

google/cloud/storage/internal/hedged_object_read_source_test.cc

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,134 @@ TEST(HedgedObjectReadSourceTest,
271271
primary_closed->get_future().get();
272272
}
273273

274+
TEST(HedgedObjectReadSourceTest, HedgeOpenFailureReleasesSlot) {
275+
// Verify that if a hedge attempt fails during stream opening (factory()
276+
// error), the hedge concurrency slot is released via RAII (SlotGuard) and is
277+
// not leaked.
278+
auto unblock_primary = std::make_shared<std::promise<void>>();
279+
auto calls = std::make_shared<std::atomic<int>>(0);
280+
auto factory = [unblock_primary,
281+
calls]() -> StatusOr<std::unique_ptr<ObjectReadSource>> {
282+
int call_count = ++*calls;
283+
if (call_count == 1) {
284+
// Primary attempt: stalls until unblocked.
285+
auto mock = std::make_unique<MockObjectReadSource>();
286+
EXPECT_CALL(*mock, Read)
287+
.WillOnce([unblock_primary](char* buf, std::size_t) {
288+
unblock_primary->get_future().get();
289+
std::string const payload = "primary";
290+
std::copy(payload.begin(), payload.end(), buf);
291+
return MakeReadResult(payload);
292+
});
293+
return std::unique_ptr<ObjectReadSource>(std::move(mock));
294+
}
295+
// Hedge attempt: fails to open.
296+
return Status(StatusCode::kUnavailable, "open failed");
297+
};
298+
299+
auto hedge_pool = std::make_shared<HedgingThreadPool>(
300+
/*max_threads=*/2, /*rate_limit=*/0.0, /*capacity=*/0.0,
301+
/*max_concurrent=*/1);
302+
303+
HedgedObjectReadSource source(MakeUnlimitedReadPool(), hedge_pool, factory,
304+
std::chrono::milliseconds(1),
305+
/*max_hedges=*/1, kUnlimitedBuffer);
306+
307+
std::vector<char> buffer(100);
308+
std::thread unblocker([unblock_primary] {
309+
std::this_thread::sleep_for(std::chrono::milliseconds(30));
310+
unblock_primary->set_value();
311+
});
312+
313+
auto result = source.Read(buffer.data(), buffer.size());
314+
unblocker.join();
315+
316+
ASSERT_THAT(result, IsOk());
317+
EXPECT_THAT(std::string(buffer.data(), result->bytes_received),
318+
Eq("primary"));
319+
EXPECT_THAT(calls->load(), Eq(2));
320+
321+
// If the slot leaked on open failure, TryAcquireHedgeToken would fail because
322+
// max_concurrent is 1.
323+
EXPECT_TRUE(hedge_pool->TryAcquireHedgeToken());
324+
hedge_pool->ReleaseHedgeSlot();
325+
}
326+
327+
TEST(HedgedObjectReadSourceTest, ZeroDelayBacksOffOnHedgeTokenExhaustion) {
328+
// Verify that when delay_ == 0ms and TryAcquireHedgeToken() returns false,
329+
// the hedging loop backs off instead of busy-spinning, allowing the primary
330+
// read to complete normally.
331+
auto unblock_primary = std::make_shared<std::promise<void>>();
332+
auto calls = std::make_shared<std::atomic<int>>(0);
333+
auto factory = [unblock_primary,
334+
calls]() -> StatusOr<std::unique_ptr<ObjectReadSource>> {
335+
++*calls;
336+
auto mock = std::make_unique<MockObjectReadSource>();
337+
EXPECT_CALL(*mock, Read)
338+
.WillOnce([unblock_primary](char* buf, std::size_t) {
339+
unblock_primary->get_future().get();
340+
std::string const payload = "primary_data";
341+
std::copy(payload.begin(), payload.end(), buf);
342+
return MakeReadResult(payload);
343+
});
344+
return std::unique_ptr<ObjectReadSource>(std::move(mock));
345+
};
346+
347+
auto hedge_pool = std::make_shared<HedgingThreadPool>(
348+
/*max_threads=*/1, /*rate_limit=*/0.0, /*capacity=*/0.0,
349+
/*max_concurrent=*/1);
350+
// Exhaust all hedge slots so TryAcquireHedgeToken fails.
351+
ASSERT_TRUE(hedge_pool->TryAcquireHedgeToken());
352+
353+
HedgedObjectReadSource source(MakeUnlimitedReadPool(), hedge_pool, factory,
354+
std::chrono::milliseconds(0),
355+
/*max_hedges=*/2, kUnlimitedBuffer);
356+
357+
std::vector<char> buffer(100);
358+
std::thread unblocker([unblock_primary] {
359+
std::this_thread::sleep_for(std::chrono::milliseconds(30));
360+
unblock_primary->set_value();
361+
});
362+
363+
auto result = source.Read(buffer.data(), buffer.size());
364+
unblocker.join();
365+
366+
ASSERT_THAT(result, IsOk());
367+
EXPECT_THAT(std::string(buffer.data(), result->bytes_received),
368+
Eq("primary_data"));
369+
EXPECT_THAT(calls->load(), Eq(1));
370+
371+
hedge_pool->ReleaseHedgeSlot();
372+
}
373+
374+
TEST(HedgedObjectReadSourceTest, NonPositiveMaxHedgesDoesNotHedge) {
375+
// Verify that negative or zero max_hedges values defensively result in 0
376+
// hedge attempts, running only the primary attempt.
377+
auto calls = std::make_shared<std::atomic<int>>(0);
378+
auto factory = [calls]() -> StatusOr<std::unique_ptr<ObjectReadSource>> {
379+
++*calls;
380+
auto mock = std::make_unique<MockObjectReadSource>();
381+
EXPECT_CALL(*mock, Read).WillOnce([](char* buf, std::size_t) {
382+
std::string const payload = "primary_only";
383+
std::copy(payload.begin(), payload.end(), buf);
384+
return MakeReadResult(payload);
385+
});
386+
return std::unique_ptr<ObjectReadSource>(std::move(mock));
387+
};
388+
389+
HedgedObjectReadSource source(MakeUnlimitedReadPool(),
390+
MakeUnlimitedHedgePool(), factory,
391+
std::chrono::milliseconds(0),
392+
/*max_hedges=*/-1, kUnlimitedBuffer);
393+
394+
std::vector<char> buffer(100);
395+
auto result = source.Read(buffer.data(), buffer.size());
396+
ASSERT_THAT(result, IsOk());
397+
EXPECT_THAT(std::string(buffer.data(), result->bytes_received),
398+
Eq("primary_only"));
399+
EXPECT_THAT(calls->load(), Eq(1));
400+
}
401+
274402
TEST(HedgedObjectReadSourceTest, PrimaryOpenErrorPropagates) {
275403
auto factory = []() -> StatusOr<std::unique_ptr<ObjectReadSource>> {
276404
return Status(StatusCode::kPermissionDenied, "uh-oh");

google/cloud/storage/internal/hedging_thread_pool.h

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -135,15 +135,28 @@ class ThreadPool {
135135
};
136136

137137
/**
138-
* A dedicated thread pool with integrated hedge throttling.
138+
* Coordinates and bounds speculative hedged requests across a storage client.
139139
*
140140
* Hedged requests are gated by `TryAcquireHedgeToken()`, which enforces two
141-
* limits: a maximum number of concurrently active hedges, and a maximum rate of
142-
* new hedges per second (a token bucket). Task execution is dispatched onto a
143-
* dedicated internal `ThreadPool`.
141+
* limits: a maximum number of concurrently active hedges (when
142+
* `max_concurrent > 0`), and a maximum rate of new hedges per second via a
143+
* token bucket (when `rate_limit > 0.0`). Setting `rate_limit <= 0.0` disables
144+
* rate limiting (unlimited hedges per second). Task execution is dispatched
145+
* onto a dedicated internal `ThreadPool`.
144146
*/
145147
class HedgingThreadPool {
146148
public:
149+
/**
150+
* Constructs a `HedgingThreadPool`.
151+
*
152+
* @param max_threads the worker pool thread limit. Clamped to at least 1.
153+
* @param rate_limit the token bucket refill rate in tokens/sec. When <= 0.0,
154+
* rate limiting is disabled (unlimited hedges per second).
155+
* @param capacity the maximum burst capacity in tokens. Clamped to at
156+
* least 1.0.
157+
* @param max_concurrent the ceiling on concurrently active hedges. When <= 0,
158+
* concurrency limiting is disabled.
159+
*/
147160
HedgingThreadPool(std::size_t max_threads, double rate_limit, double capacity,
148161
std::int64_t max_concurrent)
149162
: rate_limit_(rate_limit),
@@ -175,7 +188,8 @@ class HedgingThreadPool {
175188
* On success the caller *must* eventually call `ReleaseHedgeSlot()`.
176189
*/
177190
bool TryAcquireHedgeToken() {
178-
// Gate 1: the ceiling on concurrently active hedges.
191+
// Gate 1: the ceiling on concurrently active hedges. When
192+
// max_concurrent_hedges_ <= 0, concurrency limiting is disabled.
179193
if (max_concurrent_hedges_ > 0) {
180194
std::int64_t current =
181195
active_concurrent_hedges_.load(std::memory_order_relaxed);
@@ -185,7 +199,8 @@ class HedgingThreadPool {
185199
current, current + 1, std::memory_order_relaxed));
186200
}
187201

188-
// Gate 2: the rate limit on new hedges (token bucket).
202+
// Gate 2: the rate limit on new hedges (token bucket). When
203+
// rate_limit_ <= 0.0, rate limiting is disabled.
189204
if (rate_limit_ > 0.0) {
190205
std::lock_guard<std::mutex> lock(limiter_mutex_);
191206
Refill();
@@ -219,14 +234,14 @@ class HedgingThreadPool {
219234
tokens_ = (std::min)(tokens_capacity_, tokens_ + elapsed * rate_limit_);
220235
}
221236

222-
// Token bucket rate limiter.
237+
// Token bucket rate limiter. A rate_limit_ <= 0.0 disables rate limiting.
223238
double rate_limit_;
224239
double tokens_capacity_;
225240
double tokens_;
226241
std::chrono::steady_clock::time_point last_refill_;
227242
std::mutex limiter_mutex_;
228243

229-
// Concurrency limiter.
244+
// Concurrency limiter. A max_concurrent_hedges_ <= 0 disables limit.
230245
std::int64_t const max_concurrent_hedges_;
231246
std::atomic<std::int64_t> active_concurrent_hedges_{0};
232247

google/cloud/storage/internal/hedging_thread_pool_test.cc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,16 @@ TEST(HedgingThreadPoolTest, FractionalRateLimiter) {
179179
EXPECT_FALSE(pool.TryAcquireHedgeToken());
180180
}
181181

182+
TEST(HedgingThreadPoolTest, ZeroRateLimitDisablesRateLimiting) {
183+
// A rate limit of 0.0 disables rate limiting, allowing unlimited
184+
// acquisitions.
185+
HedgingThreadPool pool(5, 0.0, 0.0, 0);
186+
187+
for (int i = 0; i < 100; ++i) {
188+
EXPECT_TRUE(pool.TryAcquireHedgeToken());
189+
}
190+
}
191+
182192
TEST(HedgingThreadPoolTest, SafeDestructionOnWorkerThread) {
183193
TestSafeDestructionOnWorkerThread(
184194
std::make_shared<HedgingThreadPool>(1, 0.0, 0.0, 0));

google/cloud/storage/options.h

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ struct MaxReadHedgesOption {
115115
* The maximum number of threads in the thread pool used for primary reads
116116
* when `EnableReadHedgingOption` is enabled.
117117
*
118-
* Sizing defaults to at least 64 threads or 4x hardware concurrency.
118+
* Set to 0 for automatic sizing (defaults to at least 64 threads or 4x hardware
119+
* concurrency).
119120
*
120121
* @ingroup storage-options
121122
*/
@@ -127,8 +128,8 @@ struct ReadThreadPoolSizeOption {
127128
* The maximum number of threads in the thread pool used for speculative
128129
* hedged requests when `EnableReadHedgingOption` is enabled.
129130
*
130-
* Sizing defaults to `MaxConcurrentHedgesOption` if set, or at least 16
131-
* threads or 2x hardware concurrency.
131+
* Set to 0 for automatic sizing (defaults to `MaxConcurrentHedgesOption` if
132+
* set, or at least 16 threads or 2x hardware concurrency).
132133
*
133134
* @ingroup storage-options
134135
*/

0 commit comments

Comments
 (0)