Skip to content

Commit c4d8e60

Browse files
committed
storage: keep segment_set formatting bounded under fmt
Since the fmt 9 upgrade, formatter resolution for segment_set selects fmt/ranges.h's range formatter over the ostream operator<<, printing every segment and silently bypassing the 8-segment truncation added in 2023 for this same problem. On partitions with thousands of segments, formatting a log handle (e.g. "Removing: {}" in log_manager::remove) then builds a multi-megabyte string in one contiguous fmt buffer, which can fail allocation on a fragmented shard and abort the process; the allocation failure report is itself suppressed by the logger's re-entrancy silencer, so such crashes appear as bare SIGABRTs. Move the truncation into a format_to member and add an explicit full fmt::formatter specialization, which takes precedence over any partial specialization, so no future fmt upgrade or include change can route around the bound; operator<< now delegates to it. The new gtest covers the fmt dispatch path (the one that regressed) and asserts ostream output stays identical.
1 parent ff54db0 commit c4d8e60

4 files changed

Lines changed: 97 additions & 12 deletions

File tree

src/v/storage/segment_set.cc

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
#include <seastar/core/seastar.hh>
2525
#include <seastar/core/thread.hh>
2626

27+
#include <fmt/format.h>
28+
#include <fmt/ostream.h>
29+
2730
#include <algorithm>
2831
#include <exception>
2932

@@ -137,25 +140,29 @@ segment_set::upper_bound(model::term_id term) const {
137140
_handles.cbegin(), _handles.cend(), term, segment_ordering{});
138141
}
139142

140-
std::ostream& operator<<(std::ostream& o, const segment_set& s) {
141-
o << "{size: " << s.size() << ", [";
143+
fmt::iterator segment_set::format_to(fmt::iterator out) const {
144+
out = fmt::format_to(out, "{{size: {}, [", size());
142145
static constexpr size_t max_to_log = 8;
143146
static constexpr size_t halved = max_to_log / 2;
144-
if (s.size() <= max_to_log) {
145-
for (auto& p : s) {
146-
o << p;
147+
if (size() <= max_to_log) {
148+
for (const auto& p : *this) {
149+
out = fmt::format_to(out, "{}", p);
147150
}
148151
} else {
149-
for (auto it = s.begin(); it != std::next(s.begin(), halved); ++it) {
150-
o << *it;
152+
for (auto it = begin(); it != std::next(begin(), halved); ++it) {
153+
out = fmt::format_to(out, "{}", *it);
151154
}
152-
o << "...";
153-
for (auto it = std::next(s.begin(), s.size() - halved); it != s.end();
154-
++it) {
155-
o << *it;
155+
out = fmt::format_to(out, "...");
156+
for (auto it = std::next(begin(), size() - halved); it != end(); ++it) {
157+
out = fmt::format_to(out, "{}", *it);
156158
}
157159
}
158-
return o << "]}";
160+
return fmt::format_to(out, "]}}");
161+
}
162+
163+
std::ostream& operator<<(std::ostream& o, const segment_set& s) {
164+
fmt::print(o, "{}", s);
165+
return o;
159166
}
160167

161168
static bool

src/v/storage/segment_set.h

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
#pragma once
1313

14+
#include "base/format_to.h"
1415
#include "features/fwd.h"
1516
#include "storage/batch_cache.h"
1617
#include "storage/file_sanitizer_types.h"
@@ -90,6 +91,9 @@ class segment_set {
9091

9192
segment_set copy() const noexcept { return *this; }
9293

94+
/// Formats a bounded summary of the segments, as the set can be large.
95+
fmt::iterator format_to(fmt::iterator out) const;
96+
9397
private:
9498
segment_set(const segment_set&) noexcept = default;
9599

@@ -128,3 +132,25 @@ ss::future<std::optional<segment_set>>
128132
maybe_create_contiguous_segment_set(segment_set::underlying_t segs);
129133

130134
} // namespace storage
135+
136+
/// Explicit full specialization so that formatter resolution always uses the
137+
/// bounded segment_set::format_to. segment_set is a range, and without this a
138+
/// matching partial specialization (e.g. fmt/ranges.h's range formatter, which
139+
/// outranks the ostream operator<< fallback since fmt 9) would print every
140+
/// segment unbounded.
141+
template<>
142+
struct fmt::formatter<storage::segment_set> {
143+
constexpr fmt::format_parse_context::iterator
144+
parse(fmt::format_parse_context& ctx) const {
145+
auto it = ctx.begin();
146+
if (it != ctx.end() && *it != '}') {
147+
throw fmt::format_error("invalid format specifier for this type");
148+
}
149+
return it;
150+
}
151+
152+
fmt::iterator
153+
format(const storage::segment_set& s, fmt::format_context& ctx) const {
154+
return s.format_to(ctx.out());
155+
}
156+
};

src/v/storage/tests/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,7 @@ redpanda_cc_gtest(
669669
"//src/v/storage:resources",
670670
"//src/v/test_utils:gtest",
671671
"//src/v/utils:directory_walker",
672+
"@fmt",
672673
"@googletest//:gtest",
673674
"@seastar",
674675
],

src/v/storage/tests/segment_set_test.cc

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@
1717

1818
#include <seastar/core/seastar.hh>
1919

20+
#include <fmt/format.h>
21+
#include <gmock/gmock.h>
2022
#include <gtest/gtest.h>
2123

2224
#include <filesystem>
2325
#include <optional>
26+
#include <sstream>
2427

2528
static ss::logger segment_set_test_log("segment_set_test");
2629

@@ -265,4 +268,52 @@ TEST_F(SegmentSetFixtureTest, recovery) {
265268
}
266269
}
267270

271+
namespace {
272+
size_t count_occurrences(std::string_view haystack, std::string_view needle) {
273+
size_t count = 0;
274+
for (auto pos = haystack.find(needle); pos != std::string_view::npos;
275+
pos = haystack.find(needle, pos + needle.size())) {
276+
++count;
277+
}
278+
return count;
279+
}
280+
} // anonymous namespace
281+
282+
// Formatting a segment_set must stay bounded regardless of its size: it
283+
// prints at most 8 segments. The fmt path is asserted separately from
284+
// operator<< because fmt resolves formatters independently of the ostream
285+
// operator (e.g. fmt/ranges.h matches segment_set as a range) and has
286+
// silently printed every segment in the past, OOM-aborting shards
287+
// mid-log-statement on partitions with thousands of segments.
288+
TEST_F(SegmentSetFixtureTest, format_is_bounded) {
289+
using o = model::offset;
290+
size_t dir_idx = 100;
291+
auto make_set = [&](int num_segs) {
292+
ss::make_directory(ss::format("{}", dir_idx)).get();
293+
segment_set::underlying_t segs;
294+
for (int i = 0; i < num_segs; ++i) {
295+
segs.push_back(
296+
make_segment(
297+
dir_idx, test_case::segment_spec(o{2 * i}, o{2 * i + 1}))
298+
.get());
299+
}
300+
++dir_idx;
301+
return segment_set{std::move(segs)};
302+
};
303+
304+
auto large = make_set(10);
305+
auto via_fmt = fmt::format("{}", large);
306+
EXPECT_EQ(count_occurrences(via_fmt, "offset_tracker"), 8);
307+
EXPECT_THAT(via_fmt, testing::HasSubstr("{size: 10, ["));
308+
EXPECT_THAT(via_fmt, testing::HasSubstr("..."));
309+
310+
std::ostringstream os;
311+
os << large;
312+
EXPECT_EQ(os.str(), via_fmt);
313+
314+
auto small_fmt = fmt::format("{}", make_set(3));
315+
EXPECT_EQ(count_occurrences(small_fmt, "offset_tracker"), 3);
316+
EXPECT_THAT(small_fmt, testing::Not(testing::HasSubstr("...")));
317+
}
318+
268319
} // namespace storage

0 commit comments

Comments
 (0)