Skip to content

Commit 7239fce

Browse files
luohahaclaude
andcommitted
[UT] Show lake tablet metadata corruption can silently drop a delvec page
Lake tablet metadata is persisted via ProtobufFile, which has no whole-file checksum: load() only runs ParseFromString and reports Corruption iff that fails. A node-local cache copy whose raw bytes are damaged can therefore still parse as a valid protobuf while silently missing a delete-vector page, with the rowset list left intact. Add a unit test that damages a real serialized TabletMetadataPB (blind single-byte corruption plus boundary truncation) and shows there exist corruptions that load() accepts yet that drop a rowset's delvec page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: luohaha <18810541851@163.com>
1 parent f8e8a78 commit 7239fce

2 files changed

Lines changed: 225 additions & 0 deletions

File tree

be/test/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,7 @@ SET(DW_TEST_FILES
446446
./storage/lake/lake_scan_node_test.cpp
447447
./storage/lake/metacache_test.cpp
448448
./storage/lake/meta_file_test.cpp
449+
./storage/lake/lake_metadata_corruption_test.cpp
449450
./storage/lake/lake_proto_normalizer_test.cpp
450451
./storage/lake/partial_update_test.cpp
451452
./storage/lake/partial_update_vector_index_test.cpp
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
// Copyright 2021-present StarRocks, Inc. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Models the local-cache corruption behind the tablet-40561106 duplicate-PK
16+
// incident. Lake tablet metadata is stored with ProtobufFile, which has NO
17+
// whole-file checksum: load() only does ParseFromString and reports Corruption
18+
// iff that fails (see protobuf_file.cpp ProtobufFile::load). So a node-local
19+
// cache copy whose raw bytes get damaged -- as starcache can do on repeated
20+
// writes -- may still parse as a perfectly valid protobuf while silently
21+
// missing a delete-vector page.
22+
//
23+
// This test damages bytes of a real serialized metadata file (blind in-place
24+
// corruption + truncation) and shows there exist corruptions that:
25+
// * are NOT rejected by load() (parse OK, not Corruption), AND
26+
// * keep the rowset list intact, BUT
27+
// * drop rowset 2263372's delvec page from delvec_meta.
28+
// That is exactly the metadata the index rebuild loaded -> get_del_vec(2263372)
29+
// returns empty -> row 211 is re-inserted -> duplicate key.
30+
31+
#include <gtest/gtest.h>
32+
33+
#include <fstream>
34+
35+
#include "base/testutil/assert.h"
36+
#include "fs/fs_util.h"
37+
#include "gen_cpp/lake_types.pb.h"
38+
#include "storage/protobuf_file.h"
39+
40+
namespace starrocks {
41+
42+
namespace {
43+
constexpr uint32_t kRowsetNew = 2263371;
44+
constexpr uint32_t kRowsetCompact = 2263372; // the delvec page that went missing
45+
constexpr int64_t kVersion = 2323783;
46+
47+
DelvecPagePB make_page(int64_t version, uint64_t offset, uint64_t size, uint32_t crc) {
48+
DelvecPagePB p;
49+
p.set_version(version);
50+
p.set_offset(offset);
51+
p.set_size(size);
52+
p.set_crc32c(crc);
53+
return p;
54+
}
55+
56+
TabletMetadataPB make_metadata() {
57+
TabletMetadataPB full;
58+
full.set_id(40561106);
59+
full.set_version(kVersion);
60+
full.add_rowsets()->set_id(kRowsetNew);
61+
full.add_rowsets()->set_id(kRowsetCompact);
62+
auto* dm = full.mutable_delvec_meta();
63+
(*dm->mutable_delvecs())[kRowsetNew] = make_page(kVersion, 0, 64, 0x11111111);
64+
(*dm->mutable_delvecs())[kRowsetCompact] = make_page(kVersion, 64, 109, 0x5c0f5a90);
65+
return full;
66+
}
67+
68+
// How a damaged buffer looks after the exact parse load() performs.
69+
struct Outcome {
70+
bool parses = false; // ParseFromString succeeded == load() returns OK
71+
int rowsets = 0; // rowset list size
72+
bool has_compact_page = false; // delvec_meta still carries rowset 2263372's page
73+
int delvecs = 0;
74+
};
75+
Outcome classify(const std::string& bytes) {
76+
TabletMetadataPB m;
77+
if (!m.ParseFromString(bytes)) return {};
78+
Outcome o;
79+
o.parses = true;
80+
o.rowsets = m.rowsets_size();
81+
o.delvecs = m.delvec_meta().delvecs().size();
82+
o.has_compact_page = m.delvec_meta().delvecs().contains(kRowsetCompact);
83+
return o;
84+
}
85+
86+
// The dangerous class: load() would accept it (parses), the rowset list is
87+
// fully intact, yet rowset 2263372's delvec page is gone -- the silent
88+
// valid-but-incomplete metadata the rebuild consumed.
89+
bool is_dangerous(const Outcome& o) {
90+
return o.parses && o.rowsets == 2 && !o.has_compact_page;
91+
}
92+
} // namespace
93+
94+
class LakeMetadataCorruptionTest : public ::testing::Test {
95+
public:
96+
void SetUp() override {
97+
_dir = "./lake_metadata_corruption_test";
98+
(void)fs::remove_all(_dir);
99+
ASSERT_OK(fs::create_directories(_dir));
100+
}
101+
void TearDown() override { (void)fs::remove_all(_dir); }
102+
103+
protected:
104+
// Write raw (possibly corrupted) bytes to a file.
105+
void write_raw(const std::string& path, const std::string& bytes) {
106+
std::ofstream out(path, std::ios::binary | std::ios::trunc);
107+
out.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
108+
ASSERT_TRUE(out.good());
109+
}
110+
std::string _dir;
111+
};
112+
113+
// Blind in-place single-byte corruption: flip bytes at each position and check
114+
// what load() would do. We expect a non-trivial number of positions where the
115+
// file still parses cleanly but rowset 2263372's delvec page is silently lost.
116+
TEST_F(LakeMetadataCorruptionTest, in_place_byte_corruption_silently_drops_delvec_page) {
117+
const TabletMetadataPB full = make_metadata();
118+
std::string clean;
119+
ASSERT_TRUE(full.SerializeToString(&clean));
120+
ASSERT_TRUE(is_dangerous(classify(clean)) == false); // sanity: clean copy is fine
121+
ASSERT_TRUE(classify(clean).has_compact_page);
122+
123+
int parse_fail = 0; // protobuf itself rejected it (would still NOT be a checksum, but rejected)
124+
int parse_ok_same = 0; // parsed, delvec page still present
125+
int dangerous = 0; // parsed, rowsets intact, delvec page lost <-- the incident shape
126+
std::vector<size_t> dangerous_offsets;
127+
128+
// Try a few mutations per byte so we exercise tag/length/value damage.
129+
const std::vector<uint8_t> mutations = {0x00, 0xFF, 0x80, 0x01};
130+
for (size_t i = 0; i < clean.size(); ++i) {
131+
const uint8_t orig = static_cast<uint8_t>(clean[i]);
132+
for (uint8_t mv : mutations) {
133+
if (mv == orig) continue;
134+
std::string damaged = clean;
135+
damaged[i] = static_cast<char>(mv);
136+
Outcome o = classify(damaged);
137+
if (!o.parses) {
138+
++parse_fail;
139+
} else if (is_dangerous(o)) {
140+
++dangerous;
141+
if (dangerous_offsets.size() < 8) dangerous_offsets.push_back(i);
142+
} else {
143+
++parse_ok_same;
144+
}
145+
}
146+
}
147+
148+
std::cerr << "[corruption sweep] file_size=" << clean.size() << " parse_fail=" << parse_fail
149+
<< " parse_ok_other=" << parse_ok_same << " dangerous(parse_ok+rowsets_intact+page_lost)="
150+
<< dangerous << std::endl;
151+
152+
// The point: such silent-loss corruptions DO exist on this format.
153+
ASSERT_GT(dangerous, 0) << "expected at least one byte corruption that parses cleanly yet loses "
154+
"rowset 2263372's delvec page";
155+
156+
// Drive ONE of them through the real load() path end to end, to prove load()
157+
// returns OK (not Corruption) on the damaged file and yields the incomplete meta.
158+
const size_t off = dangerous_offsets.front();
159+
std::string damaged = clean;
160+
damaged[off] ^= 0xFF; // a concrete flip; exact value doesn't matter, the position does
161+
Outcome chosen = classify(damaged);
162+
if (!is_dangerous(chosen)) {
163+
// pick a mutation at this offset that is dangerous
164+
for (uint8_t mv : mutations) {
165+
std::string d = clean;
166+
d[off] = static_cast<char>(mv);
167+
if (is_dangerous(classify(d))) {
168+
damaged = d;
169+
break;
170+
}
171+
}
172+
}
173+
174+
const std::string bad_path = _dir + "/corrupted.meta";
175+
write_raw(bad_path, damaged);
176+
177+
TabletMetadataPB loaded;
178+
auto st = ProtobufFile(bad_path).load(&loaded);
179+
ASSERT_TRUE(st.ok()) << "ProtobufFile has no whole-file checksum -> damaged-but-parseable metadata "
180+
"must load as OK; got: "
181+
<< st;
182+
EXPECT_EQ(2, loaded.rowsets_size()) << "rowset list should survive the corruption";
183+
EXPECT_FALSE(loaded.delvec_meta().delvecs().contains(kRowsetCompact))
184+
<< "rowset 2263372's delvec page should be silently gone";
185+
std::cerr << "[end-to-end] corrupted byte at offset " << off
186+
<< " -> load() OK, rowsets=" << loaded.rowsets_size()
187+
<< ", delvecs=" << loaded.delvec_meta().delvecs().size() << std::endl;
188+
}
189+
190+
// The most common starcache failure shape: a short/partial copy (truncation).
191+
// Truncating at the delvec_meta field boundary leaves a valid protobuf with the
192+
// rowset list intact and NO delvec_meta at all -> get_del_vec returns empty.
193+
TEST_F(LakeMetadataCorruptionTest, truncation_before_delvec_meta_parses_with_no_delvec) {
194+
const TabletMetadataPB full = make_metadata();
195+
std::string clean;
196+
ASSERT_TRUE(full.SerializeToString(&clean));
197+
198+
// Locate the delvec_meta (field 7) region: its payload appears verbatim in
199+
// the serialized message; the field tag (0x3A) precedes its length varint.
200+
const std::string dm = full.delvec_meta().SerializeAsString();
201+
const auto dm_pos = clean.find(dm);
202+
ASSERT_NE(dm_pos, std::string::npos);
203+
// Walk back over the length varint (bytes with high bit set) to the 0x3A tag.
204+
size_t cut = dm_pos;
205+
while (cut > 0 && (static_cast<uint8_t>(clean[cut - 1]) & 0x80)) --cut; // varint continuation bytes
206+
if (cut > 0) --cut; // first/low varint byte
207+
ASSERT_GT(cut, 0u);
208+
ASSERT_EQ(static_cast<uint8_t>(clean[cut - 1]), 0x3A) << "expected delvec_meta (field 7) tag before length";
209+
const size_t tag_pos = cut - 1;
210+
211+
const std::string truncated = clean.substr(0, tag_pos); // drop delvec_meta and everything after
212+
213+
const std::string path = _dir + "/truncated.meta";
214+
write_raw(path, truncated);
215+
216+
TabletMetadataPB loaded;
217+
auto st = ProtobufFile(path).load(&loaded);
218+
ASSERT_TRUE(st.ok()) << "truncation at a field boundary still parses; got: " << st;
219+
EXPECT_EQ(2, loaded.rowsets_size());
220+
EXPECT_EQ(kVersion, loaded.version());
221+
EXPECT_EQ(0, loaded.delvec_meta().delvecs().size()) << "delvec_meta should be entirely absent";
222+
}
223+
224+
} // namespace starrocks

0 commit comments

Comments
 (0)