Skip to content

Commit 81e4c68

Browse files
committed
ct/l1: add reader-keyed l1_reader_cache
Introduce a new l1_reader_cache modeled after storage::readers_cache. Instead of caching open streams by object_id (the old approach deleted in the previous commit), the new cache stores entire reader objects keyed by topic_id_partition + offset. The cache supports: - get_reader: finds an idle reader matching tidp and offset, resets its config, and wraps it in a record_batch_reader with an RAII guard that returns it to the cache on destruction - put: inserts a reusable reader into the idle pool - TTL-based eviction (default 60s) and configurable size cap (default 128) - Graceful shutdown that waits for in-use readers to return New config properties: - cloud_topics_l1_reader_cache_eviction_timeout_ms (default 60000) - cloud_topics_l1_reader_cache_max_size (default 128, bounded 0-10000) The cache is wired through state_accessors and constructed in cloud_topics::app, but is not yet used in the fetch path. The reader gains public methods (get_flags, reset_config, is_reusable, close_stream_if_open, ntp, tidp) needed by the cache.
1 parent ab159cf commit 81e4c68

14 files changed

Lines changed: 860 additions & 9 deletions

File tree

src/v/cloud_topics/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ redpanda_cc_library(
132132
"//src/v/cloud_topics/level_one/common:file_io",
133133
"//src/v/cloud_topics/level_one/compaction:scheduler",
134134
"//src/v/cloud_topics/level_one/domain:domain_supervisor",
135+
"//src/v/cloud_topics/level_one/frontend_reader:l1_reader_cache",
135136
"//src/v/cloud_topics/level_one/frontend_reader:level_one_reader_probe",
136137
"//src/v/cloud_topics/level_one/metastore:leader_router",
137138
"//src/v/cloud_topics/level_one/metastore:replicated_metastore",

src/v/cloud_topics/app.cc

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
#include "cloud_topics/topic_manifest_upload_manager.h"
2727
#include "cluster/controller.h"
2828
#include "cluster/utils/partition_change_notifier_impl.h"
29+
#include "config/configuration.h"
2930
#include "config/node_config.h"
3031
#include "resource_mgmt/cpu_scheduling.h"
3132
#include "ssx/future-util.h"
@@ -70,6 +71,17 @@ ss::future<> app::construct(
7071

7172
co_await construct_service(_l1_reader_probe);
7273

74+
co_await construct_service(
75+
_l1_reader_cache,
76+
ss::sharded_parameter([] {
77+
return config::shard_local_cfg()
78+
.cloud_topics_l1_reader_cache_eviction_timeout_ms.bind();
79+
}),
80+
ss::sharded_parameter([] {
81+
return config::shard_local_cfg()
82+
.cloud_topics_l1_reader_cache_max_size.bind();
83+
}));
84+
7385
co_await construct_service(
7486
l1_io,
7587
config::node().l1_staging_path(),
@@ -129,6 +141,7 @@ ss::future<> app::construct(
129141
ss::sharded_parameter(
130142
[&metadata_cache] { return &metadata_cache->local(); }),
131143
ss::sharded_parameter([this] { return &_l1_reader_probe.local(); }),
144+
ss::sharded_parameter([this] { return &_l1_reader_cache.local(); }),
132145
ss::sharded_parameter([this] { return &rr_metadata_manager_.local(); }),
133146
ss::sharded_parameter([this] { return &rr_snapshot_manager_.local(); }));
134147

src/v/cloud_topics/app.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
#include "cloud_topics/level_one/common/file_io.h"
1414
#include "cloud_topics/level_one/compaction/scheduler.h"
1515
#include "cloud_topics/level_one/domain/domain_supervisor.h"
16+
#include "cloud_topics/level_one/frontend_reader/l1_reader_cache.h"
1617
#include "cloud_topics/level_one/frontend_reader/level_one_reader_probe.h"
1718
#include "cloud_topics/level_one/metastore/leader_router.h"
1819
#include "cloud_topics/level_one/metastore/replicated_metastore.h"
@@ -101,6 +102,7 @@ class app : public ssx::sharded_service_container {
101102

102103
ss::sstring _logger_name;
103104
ss::sharded<level_one_reader_probe> _l1_reader_probe;
105+
ss::sharded<l1_reader_cache> _l1_reader_cache;
104106
std::unique_ptr<data_plane_api> data_plane;
105107
ss::sharded<state_accessors> state;
106108
ss::sharded<l1::file_io> l1_io;

src/v/cloud_topics/level_one/frontend_reader/BUILD

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,28 @@ redpanda_cc_library(
1616
],
1717
)
1818

19+
redpanda_cc_library(
20+
name = "l1_reader_cache",
21+
srcs = [
22+
"l1_reader_cache.cc",
23+
],
24+
hdrs = [
25+
"l1_reader_cache.h",
26+
],
27+
visibility = ["//visibility:public"],
28+
deps = [
29+
":reader",
30+
"//src/v/cloud_topics:log_reader_config",
31+
"//src/v/cloud_topics:logger",
32+
"//src/v/config",
33+
"//src/v/container:intrusive",
34+
"//src/v/model",
35+
"//src/v/random:time_jitter",
36+
"//src/v/ssx:future_util",
37+
"@seastar",
38+
],
39+
)
40+
1941
redpanda_cc_library(
2042
name = "reader",
2143
srcs = [
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/*
2+
* Copyright 2026 Redpanda Data, Inc.
3+
*
4+
* Licensed as a Redpanda Enterprise file under the Redpanda Community
5+
* License (the "License"); you may not use this file except in compliance with
6+
* the License. You may obtain a copy of the License at
7+
*
8+
* https://github.com/redpanda-data/redpanda/blob/master/licenses/rcl.md
9+
*/
10+
11+
#include "cloud_topics/level_one/frontend_reader/l1_reader_cache.h"
12+
13+
#include "cloud_topics/logger.h"
14+
#include "random/simple_time_jitter.h"
15+
#include "ssx/future-util.h"
16+
17+
#include <seastar/core/coroutine.hh>
18+
#include <seastar/core/lowres_clock.hh>
19+
20+
namespace cloud_topics {
21+
22+
l1_reader_cache::l1_reader_cache(
23+
config::binding<std::chrono::milliseconds> eviction_timeout,
24+
config::binding<size_t> target_max_size)
25+
: _eviction_timeout(std::move(eviction_timeout))
26+
, _target_max_size(std::move(target_max_size)) {
27+
_eviction_timer.set_callback([this] {
28+
ssx::spawn_with_gate(_gate, [this] {
29+
return maybe_evict().finally([this] { arm_eviction_timer(); });
30+
});
31+
});
32+
arm_eviction_timer();
33+
}
34+
35+
l1_reader_cache::~l1_reader_cache() {
36+
vassert(
37+
_readers.empty() && _in_use.empty(),
38+
"l1_reader_cache must be stopped before destruction");
39+
}
40+
41+
std::optional<model::record_batch_reader> l1_reader_cache::get_reader(
42+
const model::topic_id_partition& tidp,
43+
const cloud_topic_log_reader_config& cfg) {
44+
if (_gate.is_closed()) {
45+
return std::nullopt;
46+
}
47+
48+
auto it = _readers.begin();
49+
while (it != _readers.end()) {
50+
if (
51+
it->reader->is_reusable()
52+
&& it->reader->next_read_lower_bound() == cfg.start_offset
53+
&& it->reader->tidp() == tidp) {
54+
break;
55+
}
56+
++it;
57+
}
58+
59+
if (it == _readers.end()) {
60+
++_cache_misses;
61+
return std::nullopt;
62+
}
63+
64+
auto& e = *it;
65+
e.reader->reset_config(cfg);
66+
++_cache_hits;
67+
68+
_readers.erase(_readers.iterator_to(e));
69+
_in_use.push_back(e);
70+
return e.make_cached_reader(this);
71+
}
72+
73+
model::record_batch_reader
74+
l1_reader_cache::put(std::unique_ptr<level_one_log_reader_impl> reader) {
75+
if (_gate.is_closed()) {
76+
return model::record_batch_reader(std::move(reader));
77+
}
78+
79+
auto* ptr = new entry{.reader = std::move(reader)}; // NOLINT
80+
_in_use.push_back(*ptr);
81+
++_readers_added;
82+
maybe_evict_size();
83+
return ptr->make_cached_reader(this);
84+
}
85+
86+
l1_reader_cache::stats l1_reader_cache::get_stats() const {
87+
return stats{
88+
.in_use_readers = _in_use.size(),
89+
.cached_readers = _readers.size(),
90+
};
91+
}
92+
93+
l1_reader_cache::entry_guard::~entry_guard() noexcept {
94+
_cache->_in_use.erase(_cache->_in_use.iterator_to(*_e));
95+
if (_e->reader->is_reusable()) {
96+
_e->last_used = ss::lowres_clock::now();
97+
_cache->_readers.push_back(*_e);
98+
} else {
99+
_cache->dispose_in_background(_e);
100+
}
101+
_cache->_in_use_reader_destroyed.broadcast();
102+
}
103+
104+
model::record_batch_reader
105+
l1_reader_cache::entry::make_cached_reader(l1_reader_cache* cache) {
106+
class cached_reader_impl final : public model::record_batch_reader::impl {
107+
public:
108+
explicit cached_reader_impl(entry* e, l1_reader_cache* c)
109+
: _underlying(e->reader.get())
110+
, _guard(e, c) {}
111+
cached_reader_impl(cached_reader_impl&&) noexcept = default;
112+
cached_reader_impl& operator=(cached_reader_impl&&) noexcept = default;
113+
cached_reader_impl(const cached_reader_impl&) = delete;
114+
cached_reader_impl& operator=(const cached_reader_impl&) = delete;
115+
116+
bool is_end_of_stream() const final {
117+
return _underlying->is_end_of_stream();
118+
}
119+
120+
ss::future<model::record_batch_reader::storage_t>
121+
do_load_slice(model::timeout_clock::time_point tout) final {
122+
return _underlying->do_load_slice(tout);
123+
}
124+
125+
std::optional<private_flags> get_flags() const final {
126+
return _underlying->get_flags();
127+
}
128+
129+
ss::future<> finally() noexcept final { return ss::now(); }
130+
131+
fmt::iterator format_to(fmt::iterator it) const final {
132+
return _underlying->format_to(it);
133+
}
134+
~cached_reader_impl() final = default;
135+
136+
private:
137+
level_one_log_reader_impl* _underlying;
138+
entry_guard _guard;
139+
};
140+
141+
return model::make_record_batch_reader<cached_reader_impl>(this, cache);
142+
}
143+
144+
ss::future<> l1_reader_cache::wait_for_no_inuse_readers() {
145+
return _in_use_reader_destroyed.wait([this] { return _in_use.empty(); });
146+
}
147+
148+
ss::future<> l1_reader_cache::stop() {
149+
if (_eviction_timer.armed()) {
150+
_eviction_timer.cancel();
151+
}
152+
co_await _gate.close();
153+
co_await wait_for_no_inuse_readers();
154+
155+
// Close all cached readers' streams.
156+
for (auto& r : _readers) {
157+
co_await r.reader->finally();
158+
}
159+
_readers.clear_and_dispose([](entry* e) {
160+
delete e; // NOLINT
161+
});
162+
}
163+
164+
void l1_reader_cache::dispose_in_background(entry* e) {
165+
if (_gate.is_closed()) {
166+
_readers.push_back(*e);
167+
} else {
168+
++_readers_evicted;
169+
ssx::spawn_with_gate(_gate, [e] {
170+
return e->reader->finally().finally([e] {
171+
delete e; // NOLINT
172+
});
173+
});
174+
}
175+
}
176+
177+
void l1_reader_cache::arm_eviction_timer() {
178+
if (_gate.is_closed()) {
179+
return;
180+
}
181+
auto timeout = _eviction_timeout();
182+
if (timeout > std::chrono::milliseconds::zero()) {
183+
_eviction_timer.arm(
184+
simple_time_jitter<ss::lowres_clock>(timeout).next_duration());
185+
}
186+
}
187+
188+
ss::future<> l1_reader_cache::maybe_evict() {
189+
auto now = ss::lowres_clock::now();
190+
uncounted_intrusive_list<entry, &entry::_hook> to_evict;
191+
192+
for (auto it = _readers.begin(); it != _readers.end();) {
193+
const auto invalid = !it->reader->is_reusable();
194+
const auto outdated = it->last_used + _eviction_timeout() < now;
195+
if (invalid || outdated) {
196+
it = _readers.erase_and_dispose(
197+
it, [&to_evict](entry* e) { to_evict.push_back(*e); });
198+
} else {
199+
++it;
200+
}
201+
}
202+
203+
for (auto it = to_evict.begin(); it != to_evict.end();) {
204+
auto* e = &*it;
205+
++it;
206+
to_evict.erase(to_evict.iterator_to(*e));
207+
co_await e->reader->finally();
208+
++_readers_evicted;
209+
delete e; // NOLINT
210+
}
211+
}
212+
213+
bool l1_reader_cache::over_size_limit() const {
214+
return !_readers.empty()
215+
&& _readers.size() + _in_use.size() > _target_max_size();
216+
}
217+
218+
void l1_reader_cache::maybe_evict_size() {
219+
if (!over_size_limit()) [[likely]] {
220+
return;
221+
}
222+
_readers.pop_front_and_dispose(
223+
[this](entry* e) { dispose_in_background(e); });
224+
}
225+
226+
} // namespace cloud_topics

0 commit comments

Comments
 (0)