This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 71a63249bbd [fix](be) Own the allocated LSN map in SharedMemtable to
fix UAF on flush task teardown (#67442)
71a63249bbd is described below
commit 71a63249bbd578a9aa18129d0cc20dfec86c5d48
Author: Nelson Boss <[email protected]>
AuthorDate: Thu Sep 3 11:57:09 2026 +0800
[fix](be) Own the allocated LSN map in SharedMemtable to fix UAF on flush
task teardown (#67442)
### What problem does this PR solve?
Issue Number: close #67428
Related PR: #66889 (introduced the UAF)
Problem Summary:
ASAN heap-use-after-free in `SharedMemtable::~SharedMemtable()` during
group-commit memtable flush task teardown, introduced by #66889.
`PartOfGroupMemtableFlushTask` holds its `SharedMemtable` by
`shared_ptr` but only a `weak_ptr` to the `FlushToken`. When `run()`'s
local `shared_ptr<FlushToken>` drops the last reference at the end of
`run()`, destruction cascades `FlushToken` -> `RowsetWriter` ->
`RowsetWriterContext`; the thread pool then destroys the task object,
and `~SharedMemtable()` dereferences the dangling raw
`RowsetWriterContext* rowset_ctx` in `remove_segment_allocated_lsns()`.
Reproduced by the nonConcurrent pipeline on two unrelated PRs (#67404,
#67402).
### What changed?
- `SharedMemtable` now owns `std::shared_ptr<SegmentAllocatedLsnMap>`
captured from the group writer context at submission; insert/remove go
through the owned map. A null map stands for "no LSN allocation"
(equivalent to `need_allocated_lsn()`, since `GroupRowsetWriter::init()`
creates the map exactly when needed). This keeps the precise cleanup
dependency alive without extending the whole `RowsetWriter` lifetime —
the approach recommended in the #67428 triage.
- Add `SegmentAllocatedLsnMap::contains_segment()` for test assertions.
- Regression tests covering: (a) the last token/writer owner released
while a group flush task finishes, (b) a queued subtask running after
its weak token expired, (c) cancellation, plus LSN-entry cleanup
assertions on the flush-error path.
### Verification
ASAN BE UT, both directions: **without** the fix the new tests abort
with the exact reported UAF (`SUMMARY: AddressSanitizer:
heap-use-after-free rowset_writer_context.h:202:9 in
doris::RowsetWriterContext::remove_segment_allocated_lsns`); **with**
the fix the `MemTableFlushExecutor*` tests pass 8/8 (two runs).
---
be/src/load/memtable/memtable_flush_executor.cpp | 17 +-
be/src/load/memtable/memtable_flush_executor.h | 8 +-
be/src/storage/binlog.h | 5 +
.../load/memtable/memtable_flush_executor_test.cpp | 171 ++++++++++++++++++++-
4 files changed, 189 insertions(+), 12 deletions(-)
diff --git a/be/src/load/memtable/memtable_flush_executor.cpp
b/be/src/load/memtable/memtable_flush_executor.cpp
index 1970d074a5e..2ad61661d4f 100644
--- a/be/src/load/memtable/memtable_flush_executor.cpp
+++ b/be/src/load/memtable/memtable_flush_executor.cpp
@@ -120,8 +120,8 @@ SharedMemtable::~SharedMemtable() {
return;
}
if (has_allocated_lsns) {
- DCHECK(rowset_ctx != nullptr);
- rowset_ctx->remove_segment_allocated_lsns(segment_id);
+ DCHECK(allocated_lsn_map != nullptr);
+ allocated_lsn_map->remove_segment(segment_id);
}
DCHECK(memtable != nullptr);
SCOPED_SWITCH_THREAD_MEM_TRACKER_LIMITER(
@@ -187,8 +187,7 @@ Status FlushToken::submit(std::shared_ptr<MemTable>
mem_table) {
shared_memtable = std::make_shared<SharedMemtable>();
shared_memtable->memtable = mem_table;
- shared_memtable->rowset_ctx =
-
const_cast<RowsetWriterContext*>(&group_rowset_writer->context());
+ shared_memtable->allocated_lsn_map =
group_rowset_writer->context().allocated_lsn_map;
// Keep data/binlog segment_id allocators in sync.
auto segment_id = DORIS_TRY(data_writer->allocate_segment_id());
auto binlog_segment_id =
DORIS_TRY(binlog_writer->allocate_segment_id());
@@ -314,13 +313,13 @@ Status FlushToken::_memtable2block(MemTable* memtable,
SharedMemtable* shared_me
shared_memtable->block_status = memtable->to_block(&block);
if (shared_memtable->block_status.ok()) {
shared_memtable->block.reset(block.release());
- auto* rowset_ctx = shared_memtable->rowset_ctx;
- DCHECK(rowset_ctx != nullptr);
- if (rowset_ctx->need_allocated_lsn() &&
shared_memtable->block->rows() > 0) {
+ // A non-null map is equivalent to
RowsetWriterContext::need_allocated_lsn():
+ // GroupRowsetWriter::init() creates the map exactly when LSN
allocation is needed.
+ const auto& lsn_map = shared_memtable->allocated_lsn_map;
+ if (lsn_map != nullptr && shared_memtable->block->rows() > 0) {
auto memtable_lsns = memtable->allocated_lsns();
DCHECK_EQ(memtable_lsns->size(),
shared_memtable->block->rows());
-
rowset_ctx->insert_segment_allocated_lsns(shared_memtable->segment_id,
- memtable_lsns);
+
lsn_map->insert_segment_allocated_lsns(shared_memtable->segment_id,
memtable_lsns);
shared_memtable->has_allocated_lsns = true;
}
}
diff --git a/be/src/load/memtable/memtable_flush_executor.h
b/be/src/load/memtable/memtable_flush_executor.h
index c66051cc5f7..c3053d483e5 100644
--- a/be/src/load/memtable/memtable_flush_executor.h
+++ b/be/src/load/memtable/memtable_flush_executor.h
@@ -28,6 +28,7 @@
#include "common/status.h"
#include "load/delta_writer/delta_writer_context.h"
#include "load/memtable/memtable.h"
+#include "storage/binlog.h"
#include "util/threadpool.h"
namespace doris {
@@ -64,7 +65,12 @@ struct SharedMemtable {
std::once_flag block_once;
Status block_status;
std::shared_ptr<Block> block;
- RowsetWriterContext* rowset_ctx = nullptr;
+ // Owns the segment LSN map so cleanup in ~SharedMemtable stays valid even
when
+ // the FlushToken's last reference drops inside
PartOfGroupMemtableFlushTask::run()
+ // before the thread pool destroys the task (and with it this
SharedMemtable).
+ // A null map means this group writes no per-row LSNs (see
GroupRowsetWriter::init,
+ // which creates the map exactly when LSN allocation is needed).
+ std::shared_ptr<segment_v2::SegmentAllocatedLsnMap> allocated_lsn_map;
bool has_allocated_lsns = false;
std::atomic<int> finished_sub_task_count {0};
diff --git a/be/src/storage/binlog.h b/be/src/storage/binlog.h
index b59eded10ce..c4e227e387a 100644
--- a/be/src/storage/binlog.h
+++ b/be/src/storage/binlog.h
@@ -181,6 +181,11 @@ public:
_seg_id_to_lsn_ids.erase(seg_id);
}
+ bool contains_segment(int64_t seg_id) const {
+ std::lock_guard<std::mutex> l(_mutex);
+ return _seg_id_to_lsn_ids.count(seg_id) > 0;
+ }
+
ConstAllocatedLsnVectorSharedPtr get_segment_allocated_lsns(int64_t
seg_id) const {
std::lock_guard<std::mutex> l(_mutex);
auto it = _seg_id_to_lsn_ids.find(seg_id);
diff --git a/be/test/load/memtable/memtable_flush_executor_test.cpp
b/be/test/load/memtable/memtable_flush_executor_test.cpp
index b53d0891f8c..ad6e27dd0c5 100644
--- a/be/test/load/memtable/memtable_flush_executor_test.cpp
+++ b/be/test/load/memtable/memtable_flush_executor_test.cpp
@@ -55,13 +55,16 @@ namespace {
class MockRowsetWriter final : public RowsetWriter {
public:
+ // `flush_enter_cnt`, when set, is incremented at the top of
flush_memtable() BEFORE the
+ // optional delay, so tests can deterministically wait for "task is inside
flush".
explicit MockRowsetWriter(std::atomic<int>* flush_cnt, bool fail_on_flush
= false,
const std::string& flush_error_msg = "mock flush
failed",
- int flush_delay_ms = 0)
+ int flush_delay_ms = 0, std::atomic<int>*
flush_enter_cnt = nullptr)
: _flush_cnt(flush_cnt),
_fail_on_flush(fail_on_flush),
_flush_error_msg(flush_error_msg),
- _flush_delay_ms(flush_delay_ms) {}
+ _flush_delay_ms(flush_delay_ms),
+ _flush_enter_cnt(flush_enter_cnt) {}
Status init(const RowsetWriterContext& ctx) override {
_context = ctx;
@@ -79,6 +82,9 @@ public:
Status flush_memtable(Block* block, int32_t segment_id, int64_t*
flush_size) override {
EXPECT_GT(block->rows(), 0);
+ if (_flush_enter_cnt != nullptr) {
+ ++(*_flush_enter_cnt);
+ }
if (_flush_delay_ms > 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(_flush_delay_ms));
}
@@ -129,6 +135,7 @@ private:
bool _fail_on_flush;
std::string _flush_error_msg;
int _flush_delay_ms;
+ std::atomic<int>* _flush_enter_cnt;
int32_t _next_segment_id = 0;
int32_t _last_segment_id = -1;
ConstAllocatedLsnVectorSharedPtr _last_seg_lsn = nullptr;
@@ -553,6 +560,166 @@ TEST_F(MemTableFlushExecutorGroupFlushTest,
TestGroupFlushTokenPartialSuccess) {
EXPECT_EQ(1, flush_token->get_stats().flush_finish_count.load());
EXPECT_EQ(0, flush_token->get_stats().flush_submit_count.load());
+ // Flush-error path: the LSN entry inserted by the data subtask (whose
_memtable2block ran
+ // call_once before the binlog flush failed) must still be cleaned up when
the shared
+ // memtable is destroyed. wait() only observes the in-run() deferred
counters, so give the
+ // pool a moment to destroy the finished task objects first.
+ auto lsn_map = group_writer->context().allocated_lsn_map;
+ ASSERT_NE(lsn_map, nullptr);
+ int32_t seg_id = data_writer->last_segment_id();
+ std::this_thread::sleep_for(std::chrono::milliseconds(200));
+ EXPECT_FALSE(lsn_map->contains_segment(seg_id));
+
+ drop_tablet(ctx.request);
+}
+
+// Regression test for the use-after-free reported in issue #67428 (scenario
a): the last
+// external FlushToken/RowsetWriter owner is released while a group flush task
is still inside
+// flush_memtable(). When that task's run() returns, its local
shared_ptr<FlushToken> drops the
+// last reference, destroying the FlushToken -> GroupRowsetWriter ->
RowsetWriterContext, and
+// the subsequent task-object destruction runs ~SharedMemtable, which used to
dereference the
+// dangling raw RowsetWriterContext*. Now SharedMemtable owns the LSN map, so
this must be safe.
+TEST_F(MemTableFlushExecutorGroupFlushTest, TestGroupFlushTaskOutlivesToken) {
+ SCOPED_INIT_THREAD_CONTEXT();
+
+ GroupFlushTestContext ctx;
+ prepare_group_flush_test_context(10004, 270068376, {4000, 4001}, &ctx);
+
+ std::atomic<int> data_flush_cnt = 0;
+ std::atomic<int> binlog_flush_cnt = 0;
+ std::atomic<int> binlog_flush_enter_cnt = 0;
+ auto data_writer = std::make_shared<MockRowsetWriter>(&data_flush_cnt);
+ auto binlog_writer = std::make_shared<MockRowsetWriter>(&binlog_flush_cnt,
false, "", 500,
+
&binlog_flush_enter_cnt);
+ std::shared_ptr<GroupRowsetWriter> group_writer;
+ ASSERT_TRUE(create_group_rowset_writer(ctx, 4, data_writer, binlog_writer,
&group_writer).ok());
+
+ std::unique_ptr<ThreadPool> pool;
+ ASSERT_TRUE(ThreadPoolBuilder("MemTableGroupFlushTestPool")
+ .set_min_threads(2)
+ .set_max_threads(2)
+ .build(&pool)
+ .ok());
+
+ std::shared_ptr<FlushToken> flush_token;
+ ASSERT_TRUE(create_group_flush_token(ctx, group_writer, &flush_token,
pool.get()).ok());
+ ASSERT_TRUE(flush_token->submit(ctx.memtable).ok());
+
+ // Capture the LSN map and segment id while the writer is still alive,
then drop every
+ // external reference to the token and the group writer while the binlog
subtask is parked
+ // inside its delayed flush_memtable() and the data subtask has fully
finished.
+ auto lsn_map = group_writer->context().allocated_lsn_map;
+ ASSERT_NE(lsn_map, nullptr);
+ while (binlog_flush_enter_cnt.load() == 0 || data_flush_cnt.load() == 0) {
+ std::this_thread::sleep_for(std::chrono::milliseconds(10));
+ }
+ flush_token.reset();
+ group_writer.reset();
+
+ // The binlog subtask finishes run(): its local token shared_ptr is now
the LAST reference,
+ // so the FlushToken and the GroupRowsetWriter (with RowsetWriterContext)
die at the end of
+ // run(), before the thread pool destroys the task and ~SharedMemtable()
runs. Before the
+ // fix this read freed memory (ASAN heap-use-after-free); after the fix
SharedMemtable owns
+ // the LSN map and the cleanup below is safe.
+ std::this_thread::sleep_for(std::chrono::milliseconds(1000));
+ EXPECT_EQ(1, binlog_flush_cnt.load());
+ EXPECT_FALSE(lsn_map->contains_segment(data_writer->last_segment_id()));
+
+ drop_tablet(ctx.request);
+}
+
+// Regression test for issue #67428 (scenario b): a queued group subtask runs
AFTER its weak
+// FlushToken reference has already expired. The token's last strong reference
dies at the end
+// of the first subtask's run(); the sibling subtask then observes the expired
token, skips its
+// flush, and its task-object destruction still runs ~SharedMemtable with LSNs
already inserted
+// by the first subtask — which must not touch freed state.
+TEST_F(MemTableFlushExecutorGroupFlushTest,
TestGroupFlushQueuedSubtaskAfterTokenExpired) {
+ SCOPED_INIT_THREAD_CONTEXT();
+
+ GroupFlushTestContext ctx;
+ prepare_group_flush_test_context(10005, 270068377, {5000, 5001}, &ctx);
+
+ std::atomic<int> data_flush_cnt = 0;
+ std::atomic<int> binlog_flush_cnt = 0;
+ std::atomic<int> data_flush_enter_cnt = 0;
+ // Single-threaded pool: the data subtask runs first (parked in its
delayed flush), the
+ // binlog subtask stays queued until the data subtask's run() has returned.
+ auto data_writer = std::make_shared<MockRowsetWriter>(&data_flush_cnt,
false, "", 500,
+
&data_flush_enter_cnt);
+ auto binlog_writer = std::make_shared<MockRowsetWriter>(&binlog_flush_cnt);
+ std::shared_ptr<GroupRowsetWriter> group_writer;
+ ASSERT_TRUE(create_group_rowset_writer(ctx, 5, data_writer, binlog_writer,
&group_writer).ok());
+
+ std::unique_ptr<ThreadPool> pool;
+ ASSERT_TRUE(ThreadPoolBuilder("MemTableGroupFlushTestPool")
+ .set_min_threads(1)
+ .set_max_threads(1)
+ .build(&pool)
+ .ok());
+
+ std::shared_ptr<FlushToken> flush_token;
+ ASSERT_TRUE(create_group_flush_token(ctx, group_writer, &flush_token,
pool.get()).ok());
+ ASSERT_TRUE(flush_token->submit(ctx.memtable).ok());
+
+ auto lsn_map = group_writer->context().allocated_lsn_map;
+ ASSERT_NE(lsn_map, nullptr);
+ // Wait until the data subtask is inside its delayed flush (the LSN entry
is already
+ // inserted by _memtable2block at this point), then drop the external
references while the
+ // data subtask's run() still holds the token alive. When its run()
returns, the local
+ // token shared_ptr is the last reference and the
FlushToken/GroupRowsetWriter die; the
+ // queued binlog subtask then runs with an expired weak token.
+ while (data_flush_enter_cnt.load() == 0) {
+ std::this_thread::sleep_for(std::chrono::milliseconds(10));
+ }
+ flush_token.reset();
+ group_writer.reset();
+
+ std::this_thread::sleep_for(std::chrono::milliseconds(1000));
+ EXPECT_EQ(1, data_flush_cnt.load());
+ // The binlog subtask observed the expired token and skipped its flush.
+ EXPECT_EQ(0, binlog_flush_cnt.load());
+ // The shared memtable was destroyed by the binlog task's destruction and
removed the
+ // segment's LSN entry through its OWNED map (no dangling
RowsetWriterContext access).
+ EXPECT_FALSE(lsn_map->contains_segment(data_writer->last_segment_id()));
+
+ drop_tablet(ctx.request);
+}
+
+// Cancelled path: after cancel(), submitted subtasks skip their flush
entirely, no LSN entry is
+// ever inserted, and the task teardown must stay clean.
+TEST_F(MemTableFlushExecutorGroupFlushTest,
TestGroupFlushTokenCancelledCleanup) {
+ SCOPED_INIT_THREAD_CONTEXT();
+
+ GroupFlushTestContext ctx;
+ prepare_group_flush_test_context(10006, 270068378, {6000, 6001}, &ctx);
+
+ std::atomic<int> data_flush_cnt = 0;
+ std::atomic<int> binlog_flush_cnt = 0;
+ auto data_writer = std::make_shared<MockRowsetWriter>(&data_flush_cnt);
+ auto binlog_writer = std::make_shared<MockRowsetWriter>(&binlog_flush_cnt);
+ std::shared_ptr<GroupRowsetWriter> group_writer;
+ ASSERT_TRUE(create_group_rowset_writer(ctx, 6, data_writer, binlog_writer,
&group_writer).ok());
+
+ std::unique_ptr<ThreadPool> pool;
+ ASSERT_TRUE(ThreadPoolBuilder("MemTableGroupFlushTestPool")
+ .set_min_threads(2)
+ .set_max_threads(2)
+ .build(&pool)
+ .ok());
+
+ std::shared_ptr<FlushToken> flush_token;
+ ASSERT_TRUE(create_group_flush_token(ctx, group_writer, &flush_token,
pool.get()).ok());
+ flush_token->cancel();
+ ASSERT_TRUE(flush_token->submit(ctx.memtable).ok());
+ ASSERT_TRUE(flush_token->wait().ok());
+
+ auto lsn_map = group_writer->context().allocated_lsn_map;
+ ASSERT_NE(lsn_map, nullptr);
+ // No flush ran, so no LSN entry was inserted and there is nothing stale
to leak.
+ std::this_thread::sleep_for(std::chrono::milliseconds(200));
+ EXPECT_EQ(0, data_flush_cnt.load());
+ EXPECT_EQ(0, binlog_flush_cnt.load());
+
drop_tablet(ctx.request);
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]