Skip to content

Commit 95d2011

Browse files
authored
Merge pull request #29781 from Lazin/ct/batcher-l0-size-fix
ct: Fix L0 object size distribution
2 parents 40e07ac + e57427a commit 95d2011

13 files changed

Lines changed: 295 additions & 79 deletions

File tree

src/v/cloud_topics/level_zero/batcher/batcher.cc

Lines changed: 112 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
#include <chrono>
3030
#include <exception>
31+
#include <limits>
3132
#include <variant>
3233

3334
using namespace std::chrono_literals;
@@ -237,72 +238,128 @@ ss::future<std::expected<std::monostate, errc>> batcher<Clock>::run_once(
237238
template<class Clock>
238239
ss::future<> batcher<Clock>::bg_controller_loop() {
239240
auto h = _gate.hold();
240-
bool more_work = false;
241241
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;
252249
}
253250
if (_as.abort_requested()) {
254251
vlog(_logger.info, "Batcher upload loop is shutting down");
255252
co_return;
256253
}
257254

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());
262259

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+
}
270263

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();
275269
}
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+
}
295358
}
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+
}
306363
}
307364
}
308365

src/v/cloud_topics/level_zero/batcher/tests/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ redpanda_cc_gtest(
5151
"//src/v/storage:record_batch_utils",
5252
"//src/v/test_utils:gtest",
5353
"//src/v/test_utils:random_bytes",
54+
"//src/v/test_utils:scoped_config",
5455
"@googletest//:gtest",
5556
"@seastar",
5657
],

src/v/cloud_topics/level_zero/batcher/tests/batcher_test.cc

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
#include "remote_mock.h"
2828
#include "storage/record_batch_builder.h"
2929
#include "test_utils/random_bytes.h"
30+
#include "test_utils/scoped_config.h"
3031
#include "test_utils/test.h"
3132

3233
#include <seastar/core/abort_source.hh>
@@ -352,6 +353,80 @@ TEST_CORO(batcher_test, expired_write_request) {
352353
}
353354
}
354355

355-
// TODO: add more tests
356-
// - behaviour in case if pending write request sizes exceed L0 object size
357-
// limit
356+
TEST_CORO(batcher_test, chunk_splitting_balances_upload_sizes) {
357+
scoped_config cfg;
358+
// Use a small threshold so test data splits into multiple chunks.
359+
cfg.get("cloud_topics_produce_batching_size_threshold")
360+
.set_value(size_t{4096});
361+
362+
remote_mock mock;
363+
mock.expect_upload_object_repeatedly();
364+
365+
cloud_storage_clients::bucket_name bucket("foo");
366+
cloud_topics::l0::write_pipeline<ss::manual_clock> pipeline;
367+
static_cluster_services cluster_services;
368+
cloud_topics::l0::batcher<ss::manual_clock> batcher(
369+
pipeline.register_write_pipeline_stage(),
370+
bucket,
371+
mock,
372+
&cluster_services);
373+
cloud_topics::l0::write_pipeline_accessor pipeline_accessor{
374+
.pipeline = &pipeline,
375+
};
376+
377+
// Push several write requests. Each has 1 batch with 10 records
378+
// (~3KB serialized), so 6 requests total ~18KB. With threshold=4096
379+
// this should produce multiple balanced chunks.
380+
const int num_requests = 6;
381+
std::vector<ss::future<std::expected<
382+
chunked_vector<cloud_topics::extent_meta>,
383+
std::error_code>>>
384+
futures;
385+
386+
const auto timeout = 10s;
387+
auto deadline = ss::manual_clock::now() + timeout;
388+
389+
for (int i = 0; i < num_requests; i++) {
390+
auto [_, records, batches] = get_random_batches(1, 10);
391+
futures.push_back(pipeline.write_and_debounce(
392+
model::controller_ntp, min_epoch, std::move(batches), deadline));
393+
}
394+
395+
// Wait for all write requests to be staged in the pipeline
396+
// before starting the batcher. subscribe() checks pre-existing
397+
// pending data, so bg_controller_loop's wait_next will return
398+
// immediately seeing all requests at once.
399+
co_await sleep_until(10ms, [&] {
400+
return pipeline_accessor.write_requests_pending(num_requests);
401+
});
402+
403+
// Start the batcher — bg_controller_loop will pull all 6 requests
404+
// in one batch and split them into balanced chunks.
405+
co_await batcher.start();
406+
407+
// Wait for all write request futures to resolve (the batcher
408+
// loop processes chunks via spawn_with_gate, which sets the
409+
// promises on each write request).
410+
auto results = co_await ss::when_all_succeed(std::move(futures));
411+
for (auto& res : results) {
412+
ASSERT_TRUE_CORO(res.has_value());
413+
}
414+
415+
co_await batcher.stop();
416+
417+
// Multiple uploads should happen since total data exceeds threshold.
418+
ASSERT_GT_CORO(mock.payloads.size(), size_t{1});
419+
420+
// Verify uploads are balanced: no upload should be excessively small
421+
// compared to the average.
422+
size_t total_payload = 0;
423+
for (const auto& p : mock.payloads) {
424+
total_payload += p.size();
425+
}
426+
size_t avg_size = total_payload / mock.payloads.size();
427+
for (size_t i = 0; i < mock.payloads.size(); i++) {
428+
EXPECT_GE(mock.payloads[i].size(), avg_size / 3)
429+
<< "Upload " << i << " size " << mock.payloads[i].size()
430+
<< " is too small relative to average " << avg_size;
431+
}
432+
}

src/v/cloud_topics/level_zero/batcher/tests/remote_mock.h

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,19 @@ class remote_mock final : public cloud_io::remote_api<ss::manual_clock> {
8888
ss::make_ready_future<cloud_io::upload_result>(res)));
8989
}
9090

91+
/// Accept any number of upload_object calls, recording keys and payloads.
92+
void expect_upload_object_repeatedly(
93+
cloud_io::upload_result res = cloud_io::upload_result::success) {
94+
EXPECT_CALL(*this, upload_object(::testing::_))
95+
.WillRepeatedly(
96+
[this,
97+
res](const cloud_io::basic_upload_request<ss::manual_clock>& req) {
98+
keys.push_back(req.transfer_details.key);
99+
payloads.push_back(iobuf_to_bytes(req.payload));
100+
return ss::make_ready_future<cloud_io::upload_result>(res);
101+
});
102+
}
103+
91104
static std::deque<ss::sstring>
92105
convert_bytes_to_string(const chunked_vector<bytes>& expected) {
93106
std::deque<ss::sstring> result;

src/v/cloud_topics/level_zero/common/level_zero_probe.cc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,14 @@ void write_request_scheduler_probe::setup_internal_metrics(bool disable) {
252252
"active_groups",
253253
[this] { return _active_groups; },
254254
sm::description("Number of active upload groups in the scheduler."),
255+
labels),
256+
257+
sm::make_gauge(
258+
"next_stage_bytes",
259+
[this] { return _next_stage_bytes; },
260+
sm::description(
261+
"Bytes buffered in the next pipeline stage, used for "
262+
"group split/merge decisions."),
255263
labels)});
256264
}
257265
read_merge_probe::read_merge_probe(bool disable) {

src/v/cloud_topics/level_zero/common/level_zero_probe.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ class write_request_scheduler_probe {
114114

115115
void set_active_groups(uint64_t count) { _active_groups = count; }
116116

117+
void set_next_stage_bytes(uint64_t bytes) { _next_stage_bytes = bytes; }
118+
117119
private:
118120
void setup_internal_metrics(bool disable);
119121

@@ -128,6 +130,8 @@ class write_request_scheduler_probe {
128130
uint64_t _rx_bytes_xshard{0};
129131
/// Number of active upload groups
130132
uint64_t _active_groups{0};
133+
/// Bytes buffered in the next pipeline stage
134+
uint64_t _next_stage_bytes{0};
131135

132136
metrics::internal_metric_groups _metrics;
133137
};

src/v/cloud_topics/level_zero/pipeline/read_pipeline.cc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,8 @@ read_pipeline<Clock>::get_fetch_requests(
204204
el._hook.unlink();
205205
result.requests.push_back(el);
206206
}
207-
result.complete = pending.empty();
207+
result.complete = std::none_of(
208+
it, pending.end(), [stage](const auto& r) { return r.stage == stage; });
208209
vlog(
209210
logger.debug,
210211
"get_fetch_requests returned {} requests which are querying {} ({}B)",

src/v/cloud_topics/level_zero/pipeline/tests/write_pipeline_test.cc

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,3 +516,44 @@ TEST_CORO(write_pipeline_test, max_requests_limit) {
516516

517517
ASSERT_TRUE_CORO(accessor.write_requests_pending(0));
518518
}
519+
520+
TEST_CORO(write_pipeline_test, enqueue_foreign_request_accounts_bytes) {
521+
// Verify that enqueue_foreign_request updates _stage_bytes for
522+
// the destination stage. This was previously missing, causing
523+
// the scheduler to underreport cross-shard work.
524+
cloud_topics::l0::write_pipeline<ss::manual_clock> pipeline;
525+
526+
auto stage1 = pipeline.register_write_pipeline_stage();
527+
auto stage2 = pipeline.register_write_pipeline_stage();
528+
529+
ASSERT_EQ_CORO(pipeline.stage_bytes(stage2.id()), 0);
530+
531+
const auto timeout = ss::manual_clock::now() + 10s;
532+
533+
auto make_chunk = [&]() -> ss::future<cloud_topics::l0::serialized_chunk> {
534+
chunked_vector<model::record_batch> batches;
535+
auto data = co_await model::test::make_random_batches(
536+
{.count = 1, .records = 5});
537+
std::ranges::move(std::move(data), std::back_inserter(batches));
538+
co_return co_await cloud_topics::l0::serialize_batches(
539+
std::move(batches));
540+
};
541+
542+
auto chunk = co_await make_chunk();
543+
auto req
544+
= std::make_unique<cloud_topics::l0::write_request<ss::manual_clock>>(
545+
model::controller_ntp, min_epoch, std::move(chunk), timeout);
546+
auto expected_size = req->size_bytes();
547+
548+
// enqueue_foreign_request should account bytes at the next stage
549+
stage1.enqueue_foreign_request(*req, false);
550+
551+
ASSERT_EQ_CORO(pipeline.stage_bytes(stage2.id()), expected_size);
552+
553+
// Pull from stage2 — bytes should be released
554+
auto res = stage2.pull_write_requests(std::numeric_limits<size_t>::max());
555+
ASSERT_EQ_CORO(res.requests.size(), 1);
556+
ASSERT_EQ_CORO(pipeline.stage_bytes(stage2.id()), 0);
557+
558+
res.requests.front().set_value(chunked_vector<cloud_topics::extent_meta>{});
559+
}

0 commit comments

Comments
 (0)