Skip to content

Commit aa474c1

Browse files
committed
[fix](outfile) Address atomic cleanup review findings
### What problem does this PR solve? Issue Number: None Related PR: apache#67328 Problem Summary: Follow-up review found that atomic OUTFILE finalization could use the wrong Nereids deadline, contact old BEs, publish the marker before all receiver commits, miss late cleanup and shutdown races, and regress local or multipart durability. Keep the protocol version-gated, order the global decision before marker and client publication, and preserve bounded rollback ownership during the live query lifecycle. ### Release note None ### Check List (For Author) - Test: Unit Test - BE ASAN unit tests: 36/36 passed. - FE unit tests: 4/4 passed with Checkstyle and source/test compilation. - Build hygiene and clang-format 16 checks passed. - Clang-tidy was attempted but could not analyze the existing tree because of unrelated toolchain and header diagnostics. - Behavior changed: Yes. Supported execution versions publish OUTFILE success only after every receiver commits; older versions retain the legacy path. - Does this need documentation: No
1 parent 8f4293f commit aa474c1

15 files changed

Lines changed: 222 additions & 24 deletions

File tree

be/src/exec/sink/writer/vfile_result_writer.cpp

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,11 @@ Status VFileResultWriter::_create_file_writer(const std::string& file_name) {
179179
// Create/open can publish a path before returning an error, so claim deterministic ownership
180180
// first. A separate filesystem preserves Broker's existing per-path endpoint selection.
181181
_created_files.emplace_back(_file_system, file_name);
182-
const io::FileWriterOptions options {.write_file_cache = false, .sync_file_data = false};
182+
// Local OUTFILE historically synced successful closes; preserve that durability while remote
183+
// writers keep the existing no-sync option to avoid redundant flushes.
184+
const io::FileWriterOptions options {
185+
.write_file_cache = false,
186+
.sync_file_data = _storage_type == TStorageBackendType::LOCAL};
183187
RETURN_IF_ERROR(_file_system->create_file(file_name, &_file_writer_impl, &options));
184188
switch (_file_opts->file_format) {
185189
case TFileFormatType::FORMAT_CSV_PLAIN:

be/src/exec/sink/writer/vfile_result_writer.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ class VFileResultWriter final : public AsyncResultWriter {
8080
private:
8181
FRIEND_TEST(VFileResultWriterTest, FailedCloseRemovesClosedOutputFile);
8282
FRIEND_TEST(VFileResultWriterTest, FailedCloseRemovesOnlyOwnedOutputFiles);
83+
FRIEND_TEST(VFileResultWriterTest, LocalOutfilePreservesSynchronousClose);
8384

8485
Status _write_file(const Block& block);
8586

be/src/io/fs/s3_file_writer.cpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,10 +538,12 @@ Status S3FileWriter::_complete() {
538538
return {resp.status.code, std::move(resp.status.msg)};
539539
}
540540

541+
// CompleteMultipartUpload publishes the object and consumes the upload ID. A later HEAD
542+
// failure must be cleaned up as an object, not retried as an already-finished multipart upload.
543+
_multipart_upload_completed = true;
541544
RETURN_IF_ERROR(check_after_upload(client.get(), resp, _obj_storage_path_opts, _bytes_appended,
542545
"complete_multipart"));
543546

544-
_multipart_upload_completed = true;
545547
s3_file_created_total << 1;
546548
return Status::OK();
547549
}

be/src/runtime/result_block_buffer.cpp

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,15 @@ Status ResultBlockBuffer<ResultCtxType>::add_outfile_cleanup(OutfileCleanup clea
145145
return Status::OK();
146146
}
147147
if (run_cleanup) {
148-
Status status = cleanup();
149-
if (!status.ok()) {
150-
std::lock_guard<std::mutex> l(_lock);
151-
_outfile_cleanups.emplace_back(std::move(cleanup));
148+
Status status;
149+
for (int attempt = 0; attempt < 3; ++attempt) {
150+
status = cleanup();
151+
if (status.ok()) {
152+
return status;
153+
}
152154
}
155+
// Cancellation removed this buffer from the manager, so bounded inline retries are the
156+
// last live-query owner for a cleanup registered after that point.
153157
return status;
154158
}
155159
return Status::OK();

be/src/runtime/result_buffer_mgr.cpp

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ void ResultBufferMgr::stop() {
6363
}
6464
std::vector<TUniqueId> remaining_ids;
6565
{
66-
std::shared_lock<std::shared_mutex> rlock(_buffer_map_lock);
66+
std::unique_lock<std::shared_mutex> wlock(_buffer_map_lock);
67+
// Closing the registration gate under the map lock keeps the shutdown snapshot complete.
68+
_stopping = true;
6769
remaining_ids.reserve(_buffer_map.size());
6870
for (const auto& item : _buffer_map) {
6971
remaining_ids.emplace_back(item.first);
@@ -89,6 +91,9 @@ Status ResultBufferMgr::create_sender(const TUniqueId& unique_id, int buffer_siz
8991
std::shared_ptr<arrow::Schema> schema) {
9092
{
9193
std::shared_lock<std::shared_mutex> rlock(_buffer_map_lock);
94+
if (_stopping) {
95+
return Status::Cancelled("ResultBufferMgr is stopping");
96+
}
9297
auto iter = _buffer_map.find(unique_id);
9398

9499
if (_buffer_map.end() != iter) {
@@ -108,6 +113,9 @@ Status ResultBufferMgr::create_sender(const TUniqueId& unique_id, int buffer_siz
108113

109114
{
110115
std::unique_lock<std::shared_mutex> wlock(_buffer_map_lock);
116+
if (_stopping) {
117+
return Status::Cancelled("ResultBufferMgr is stopping");
118+
}
111119
_buffer_map.insert(std::make_pair(unique_id, control_block));
112120
// ResultBlockBufferBase should destroy after max_timeout
113121
// for exceed max_timeout FE will return timeout to client

be/src/runtime/result_buffer_mgr.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ class ResultBufferMgr {
9090
std::shared_mutex _buffer_map_lock;
9191
// buffer block map
9292
BufferMap _buffer_map;
93+
bool _stopping = false;
9394

9495
// lock for timeout map
9596
std::mutex _timeout_lock;

be/src/service/internal_service.cpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,10 @@ void cleanup_expired_outfile_marker_states(std::chrono::steady_clock::time_point
167167
}
168168
}
169169
for (auto it = outfile_marker_states.begin(); it != outfile_marker_states.end();) {
170-
if (now - it->second.updated_at >= OUTFILE_MARKER_TOMBSTONE_TTL) {
170+
// A failed marker delete retains the only in-process rollback fence and ownership record.
171+
// Expire state only after the owned path has been deleted successfully.
172+
if (it->second.owned_path.empty() &&
173+
now - it->second.updated_at >= OUTFILE_MARKER_TOMBSTONE_TTL) {
171174
it = outfile_marker_states.erase(it);
172175
} else {
173176
++it;

be/test/exec/sink/writer/vfile_result_writer_test.cpp

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@
2121

2222
#include <filesystem>
2323

24+
#include "exec/operator/result_sink_operator.h"
2425
#include "format/transformer/vorc_transformer.h"
2526
#include "format/transformer/vparquet_transformer.h"
2627
#include "io/fs/file_writer.h"
2728
#include "io/fs/local_file_system.h"
29+
#include "io/fs/local_file_writer.h"
30+
#include "runtime/runtime_state.h"
2831
#include "util/slice.h"
2932

3033
namespace doris {
@@ -131,4 +134,34 @@ TEST(VFileResultWriterTest, AbortedFormatStreamsDoNotCloseRawWriter) {
131134
EXPECT_EQ(orc_writer.close_count, 0);
132135
}
133136

137+
TEST(VFileResultWriterTest, LocalOutfilePreservesSynchronousClose) {
138+
const auto directory =
139+
std::filesystem::temp_directory_path() / "doris_vfile_result_writer_sync_close";
140+
const auto path = directory / "part.csv";
141+
std::filesystem::remove_all(directory);
142+
std::filesystem::create_directories(directory);
143+
144+
TResultFileSinkOptions thrift_options;
145+
thrift_options.file_path = directory.string() + "/";
146+
thrift_options.file_format = TFileFormatType::FORMAT_CSV_PLAIN;
147+
thrift_options.file_suffix = "csv";
148+
thrift_options.with_bom = false;
149+
ResultFileOptions file_options(thrift_options);
150+
RuntimeState state;
151+
VExprContextSPtrs output_exprs;
152+
VFileResultWriter writer(TDataSink {}, output_exprs, nullptr, nullptr);
153+
writer._state = &state;
154+
writer._file_opts = &file_options;
155+
writer._storage_type = TStorageBackendType::LOCAL;
156+
157+
ASSERT_TRUE(writer._create_file_writer(path.string()).ok());
158+
const auto* local_writer =
159+
dynamic_cast<const io::LocalFileWriter*>(writer._file_writer_impl.get());
160+
ASSERT_NE(local_writer, nullptr);
161+
EXPECT_TRUE(local_writer->_sync_data);
162+
163+
ASSERT_FALSE(writer.close(Status::Cancelled("test cleanup")).ok());
164+
std::filesystem::remove_all(directory);
165+
}
166+
134167
} // namespace doris

be/test/io/fs/s3_file_writer_test.cpp

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,6 +1208,9 @@ class SimpleMockObjStorageClient : public io::ObjStorageClientTestStub {
12081208

12091209
ObjStorageHeadResult head_object(const ObjStoragePath& opts) override {
12101210
std::lock_guard lock(_mutex);
1211+
if (on_head) {
1212+
on_head();
1213+
}
12111214
return {.resp = ObjStorageResponse::OK(),
12121215
.file_size = static_cast<int64_t>(objects[opts.path.native()].size())};
12131216
}
@@ -1272,6 +1275,7 @@ class SimpleMockObjStorageClient : public io::ObjStorageClientTestStub {
12721275
int complete_multipart_count = 0;
12731276
int abort_multipart_count = 0;
12741277
int abort_failures_remaining = 0;
1278+
std::function<void()> on_head;
12751279
std::vector<std::string> abort_upload_ids;
12761280

12771281
// Structures to store input parameters for each call
@@ -1314,6 +1318,7 @@ class SimpleMockObjStorageClient : public io::ObjStorageClientTestStub {
13141318
complete_multipart_count = 0;
13151319
abort_multipart_count = 0;
13161320
abort_failures_remaining = 0;
1321+
on_head = nullptr;
13171322
abort_upload_ids.clear();
13181323

13191324
create_multipart_params.clear();
@@ -1382,6 +1387,25 @@ TEST_F(S3FileWriterTest, AbortRetryReusesMultipartUploadId) {
13821387
EXPECT_EQ(mock_client->complete_multipart_count, 0);
13831388
}
13841389

1390+
TEST_F(S3FileWriterTest, MarksMultipartCompletedBeforePostUploadCheck) {
1391+
auto [mock_client, writer] = create_s3_client("complete-before-post-upload-check");
1392+
std::string content(config::s3_write_buffer_size, 'a');
1393+
const bool original_check_after_upload = config::enable_s3_object_check_after_upload;
1394+
Defer restore_config {
1395+
[&] { config::enable_s3_object_check_after_upload = original_check_after_upload; }};
1396+
config::enable_s3_object_check_after_upload = true;
1397+
bool completion_flag_during_head = false;
1398+
mock_client->on_head = [&] {
1399+
completion_flag_during_head = writer->_multipart_upload_completed;
1400+
};
1401+
1402+
ASSERT_TRUE(writer->append(content).ok());
1403+
ASSERT_TRUE(writer->close().ok());
1404+
1405+
EXPECT_EQ(mock_client->complete_multipart_count, 1);
1406+
EXPECT_TRUE(completion_flag_during_head);
1407+
}
1408+
13851409
/**
13861410
* Generate test data for S3FileWriter boundary tests.
13871411
* Returns a vector of sizes that we'll use to generate data on demand.

be/test/runtime/result_buffer_mgr_test.cpp

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,41 @@ TEST_F(ResultBufferMgrTest, cancel_no_block) {
115115
EXPECT_FALSE(buffer_mgr.cancel(query_id, Status::InternalError("")));
116116
}
117117

118+
TEST_F(ResultBufferMgrTest, RejectsNewSenderAfterStop) {
119+
ResultBufferMgr buffer_mgr;
120+
buffer_mgr.stop();
121+
122+
TUniqueId query_id;
123+
query_id.lo = 10;
124+
query_id.hi = 100;
125+
std::shared_ptr<ResultBlockBufferBase> control_block;
126+
127+
EXPECT_FALSE(buffer_mgr.create_sender(query_id, 1024, &control_block, &_state, false).ok());
128+
EXPECT_EQ(control_block, nullptr);
129+
}
130+
131+
TEST_F(ResultBufferMgrTest, LateOutfileCleanupRetriesAfterCancellation) {
132+
ResultBufferMgr buffer_mgr;
133+
TUniqueId query_id;
134+
query_id.lo = 11;
135+
query_id.hi = 101;
136+
137+
std::shared_ptr<ResultBlockBufferBase> control_block;
138+
ASSERT_TRUE(buffer_mgr.create_sender(query_id, 1024, &control_block, &_state, false).ok());
139+
ASSERT_TRUE(buffer_mgr.cancel(query_id, Status::Cancelled("injected cancellation")));
140+
141+
int cleanup_attempts = 0;
142+
EXPECT_TRUE(control_block
143+
->add_outfile_cleanup([&] {
144+
++cleanup_attempts;
145+
return cleanup_attempts == 1
146+
? Status::IOError("injected transient cleanup failure")
147+
: Status::OK();
148+
})
149+
.ok());
150+
EXPECT_EQ(cleanup_attempts, 2);
151+
}
152+
118153
TEST_F(ResultBufferMgrTest, OutfileAbortCleansRegisteredAndLateFiles) {
119154
ResultBufferMgr buffer_mgr;
120155
TUniqueId query_id;

0 commit comments

Comments
 (0)