|
28 | 28 |
|
29 | 29 | #include <chrono> |
30 | 30 | #include <exception> |
| 31 | +#include <limits> |
31 | 32 | #include <variant> |
32 | 33 |
|
33 | 34 | using namespace std::chrono_literals; |
@@ -237,72 +238,128 @@ ss::future<std::expected<std::monostate, errc>> batcher<Clock>::run_once( |
237 | 238 | template<class Clock> |
238 | 239 | ss::future<> batcher<Clock>::bg_controller_loop() { |
239 | 240 | auto h = _gate.hold(); |
240 | | - bool more_work = false; |
241 | 241 | while (!_as.abort_requested()) { |
242 | | - if (!more_work) { |
243 | | - auto wait_res = co_await _stage.wait_next(&_as); |
244 | | - if (!wait_res.has_value()) { |
245 | | - // Shutting down |
246 | | - vlog( |
247 | | - _logger.info, |
248 | | - "Batcher upload loop is shutting down {}", |
249 | | - wait_res.error()); |
250 | | - co_return; |
251 | | - } |
| 242 | + auto wait_res = co_await _stage.wait_next(&_as); |
| 243 | + if (!wait_res.has_value()) { |
| 244 | + vlog( |
| 245 | + _logger.info, |
| 246 | + "Batcher upload loop is shutting down {}", |
| 247 | + wait_res.error()); |
| 248 | + co_return; |
252 | 249 | } |
253 | 250 | if (_as.abort_requested()) { |
254 | 251 | vlog(_logger.info, "Batcher upload loop is shutting down"); |
255 | 252 | co_return; |
256 | 253 | } |
257 | 254 |
|
258 | | - // Acquire semaphore units to limit concurrent background fibers. |
259 | | - // This blocks until a slot is available. |
260 | | - auto units_fut = co_await ss::coroutine::as_future( |
261 | | - ss::get_units(_upload_sem, 1, _as)); |
| 255 | + // Pull all available write requests at once. |
| 256 | + auto all = _stage.pull_write_requests( |
| 257 | + std::numeric_limits<size_t>::max(), |
| 258 | + std::numeric_limits<size_t>::max()); |
262 | 259 |
|
263 | | - auto list = _stage.pull_write_requests( |
264 | | - config::shard_local_cfg() |
265 | | - .cloud_topics_produce_batching_size_threshold(), |
266 | | - config::shard_local_cfg() |
267 | | - .cloud_topics_produce_cardinality_threshold()); |
268 | | - |
269 | | - bool complete = list.complete; |
| 260 | + if (all.requests.empty()) { |
| 261 | + continue; |
| 262 | + } |
270 | 263 |
|
271 | | - if (units_fut.failed()) { |
272 | | - auto ex = units_fut.get_exception(); |
273 | | - vlog(_logger.info, "Batcher upload loop is shutting down: {}", ex); |
274 | | - co_return; |
| 264 | + // Calculate total size and split into evenly-sized chunks, |
| 265 | + // each close to the configured threshold. |
| 266 | + size_t total_size = 0; |
| 267 | + for (const auto& wr : all.requests) { |
| 268 | + total_size += wr.size_bytes(); |
275 | 269 | } |
276 | | - auto units = std::move(units_fut.get()); |
277 | | - |
278 | | - // We can spawn the work in the background without worrying about memory |
279 | | - // usage because the background fibers is holding units acquired above. |
280 | | - ssx::spawn_with_gate( |
281 | | - _gate, |
282 | | - [this, list = std::move(list), units = std::move(units)]() mutable { |
283 | | - return run_once(std::move(list)) |
284 | | - .then([this](std::expected<std::monostate, errc> res) { |
285 | | - if (!res.has_value()) { |
286 | | - if (res.error() == errc::shutting_down) { |
287 | | - vlog( |
288 | | - _logger.info, |
289 | | - "Batcher upload loop is shutting down"); |
290 | | - } else { |
291 | | - vlog( |
292 | | - _logger.info, |
293 | | - "Batcher upload loop error: {}", |
294 | | - res.error()); |
| 270 | + |
| 271 | + auto threshold = config::shard_local_cfg() |
| 272 | + .cloud_topics_produce_batching_size_threshold(); |
| 273 | + |
| 274 | + // If we will allow every chunk to be 'threshold' size |
| 275 | + // then the last chunk in the list has an opportunity to be |
| 276 | + // much smaller than the rest. Consider a case when the threshold |
| 277 | + // is 4MiB and we got 8MiB + 1KiB in one iteration. If we will |
| 278 | + // upload two 4MiB objects we will have to upload 1KiB object next. |
| 279 | + // The solution is to allow upload size to deviate more if it allows |
| 280 | + // us to avoid overly small objects (which will impact TCO). |
| 281 | + // |
| 282 | + // The computation below can yield small target_chunk_size in case |
| 283 | + // if total_size is below the threshold. If the total_size exceeds |
| 284 | + // the threshold the target_chunk_size can either overshoot the |
| 285 | + // threshold or undershoot. In both cases the error is bounded by |
| 286 | + // about 50%. The largest error is an overshoot in case if |
| 287 | + // the total_size is 6MiB - 1 byte and the threshold is 4MiB. The |
| 288 | + // num_chunks in this case is 1 and the target_chunk_size is approx. |
| 289 | + // 6MiB which is 2MiB over the threshold (50% of 4MiB threshold). |
| 290 | + // If the total_size is 6MiB the num_chunks will be 2 and the |
| 291 | + // target_chunk_size will be 3MiB which is 1MiB below the threshold |
| 292 | + // (or 25% of 4MiB). |
| 293 | + size_t num_chunks = std::max( |
| 294 | + size_t{1}, (total_size + threshold / 2) / threshold); |
| 295 | + size_t target_chunk_size = total_size / num_chunks; |
| 296 | + |
| 297 | + vlog( |
| 298 | + _logger.trace, |
| 299 | + "Splitting {} ({} bytes) into {} chunks, target chunk size: {} " |
| 300 | + "({})", |
| 301 | + human::bytes(total_size), |
| 302 | + total_size, |
| 303 | + num_chunks, |
| 304 | + human::bytes(target_chunk_size), |
| 305 | + target_chunk_size); |
| 306 | + |
| 307 | + for (size_t i = 0; i < num_chunks && !all.requests.empty(); ++i) { |
| 308 | + auto units_fut = co_await ss::coroutine::as_future( |
| 309 | + ss::get_units(_upload_sem, 1, _as)); |
| 310 | + |
| 311 | + if (units_fut.failed()) { |
| 312 | + auto ex = units_fut.get_exception(); |
| 313 | + vlog( |
| 314 | + _logger.info, "Batcher upload loop is shutting down: {}", ex); |
| 315 | + co_return; |
| 316 | + } |
| 317 | + auto units = std::move(units_fut.get()); |
| 318 | + |
| 319 | + // Build a chunk by moving requests from the pulled list |
| 320 | + // until we reach the target size (unless it's the last |
| 321 | + // chunk, upload everything in this case). |
| 322 | + typename write_pipeline<Clock>::write_requests_list chunk( |
| 323 | + all._parent, all._ps); |
| 324 | + |
| 325 | + size_t chunk_size = 0; |
| 326 | + bool is_last = (i == num_chunks - 1); |
| 327 | + while (!all.requests.empty()) { |
| 328 | + auto& wr = all.requests.front(); |
| 329 | + auto sz = wr.size_bytes(); |
| 330 | + chunk_size += sz; |
| 331 | + wr._hook.unlink(); |
| 332 | + chunk.requests.push_back(wr); |
| 333 | + // Allow the last chunk to be larger than the target |
| 334 | + // to avoid small objects. |
| 335 | + if (!is_last && chunk_size >= target_chunk_size) { |
| 336 | + break; |
| 337 | + } |
| 338 | + } |
| 339 | + |
| 340 | + ssx::spawn_with_gate( |
| 341 | + _gate, |
| 342 | + [this, |
| 343 | + chunk = std::move(chunk), |
| 344 | + units = std::move(units)]() mutable { |
| 345 | + return run_once(std::move(chunk)) |
| 346 | + .then([this](std::expected<std::monostate, errc> res) { |
| 347 | + if (!res.has_value()) { |
| 348 | + if (res.error() == errc::shutting_down) { |
| 349 | + vlog( |
| 350 | + _logger.info, |
| 351 | + "Batcher upload loop is shutting down"); |
| 352 | + } else { |
| 353 | + vlog( |
| 354 | + _logger.info, |
| 355 | + "Batcher upload loop error: {}", |
| 356 | + res.error()); |
| 357 | + } |
295 | 358 | } |
296 | | - } |
297 | | - }) |
298 | | - .finally([u = std::move(units)] {}); |
299 | | - }); |
300 | | - |
301 | | - // The work is spawned in the background so we can grab data for the |
302 | | - // next L0 object. If complete==true, all pending requests were pulled, |
303 | | - // so wait for more. If complete==false, there are more pending |
304 | | - // requests. |
305 | | - more_work = !complete; |
| 359 | + }) |
| 360 | + .finally([u = std::move(units)] {}); |
| 361 | + }); |
| 362 | + } |
306 | 363 | } |
307 | 364 | } |
308 | 365 |
|
|
0 commit comments