This is an automated email from the ASF dual-hosted git repository.
sollhui 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 8d88f1cc6f7 [perf](load) introduce sequential prefetch for
FileScannerV2 CSV reads (#69716)
8d88f1cc6f7 is described below
commit 8d88f1cc6f738a331ecfe024a2f1560c770068bb
Author: hui lai <[email protected]>
AuthorDate: Thu Oct 29 15:20:15 2026 +0800
[perf](load) introduce sequential prefetch for FileScannerV2 CSV reads
(#69716)
### What problem does this PR solve?
Issue Number: None
Related PR: #69333
Problem Summary:
After CSV ingestion switched to FileScannerV2, `DelimitedTextReader`
opened remote files directly through `FileFactory`, bypassing the
sequential prefetch used by the original CSV reader. S3 reads therefore
block the scanner instead of overlapping remote I/O with CSV parsing.
Restore `DelegateReader::create_file_reader(SEQUENTIAL)` in the shared
CSV/Text reader, passing the shared IOContext and adjusted split range.
Unknown-size first splits use an end sentinel bounded by the physical
file size. Tracing remains outside the prefetch layer, and shared
stream-load pipes retain their existing ownership and cancellation
behavior.
To safely integrate asynchronous reads with V2 scanner statistics:
- Each prefetch buffer owns its cache statistics and an IOContext
snapshot. The consumer merges completed statistics exactly once before
consuming, reusing or closing the buffer. Workers never update the
scanner's cache-statistics object.
- Close drains active reads even after a foreground timeout, keeping
borrowed scanner state alive until I/O completes. Queued tasks observe
CLOSED and skip reading. Cleanup can consequently take longer than
`buffered_reader_read_timeout_ms` while an underlying request is
outstanding.
- The ring modulus uses allocated capacity: a 6 MiB setting allocates
one 4 MiB buffer and wraps at 4 MiB. Settings below 4 MiB use one
buffer; the 16 MiB default is unchanged.
- CSV/Text filesystem readers close before collecting prefetch profile
counters. Repeated close does not publish counters twice.
### Performance
For the TPC-H dataset load benchmark, elapsed time decreased from
**2617.33 s** to **1696.06 s**, saving **921.27 s**. This is a **35.2%
reduction in load time**, or approximately **1.54x speedup**, for the
measured workload.
| Dataset | Before | After | Load time reduction | Speedup |
| --- | ---: | ---: | ---: | ---: |
| TPC-H | 2617.33 s | 1696.06 s | 35.2% | 1.54x |
### Release note
Restore asynchronous S3 prefetch for FileScannerV2 CSV and text reads,
with safe cache-statistics aggregation and cleanup, consistent ring
capacity and populated prefetch profiles.
### Check List (For Author)
- Test:
- Added S3 mock coverage for whole-file, mid-row split and unknown-size
reads; checks row values, outer tracing, background S3 reads, prefetch
profile publication and repeated close.
- Added buffered-reader tests for four concurrent workers with isolated
cache statistics, partial consumption, slot reuse, unconsumed-buffer
collection, successful reads finishing after foreground timeout,
explicit close/destruction, and exact bytes across 0/1/6/10/16 MiB ring
settings from zero/nonzero offsets. Added a shared stream-pipe ownership
test.
- Passed Clang 16 syntax checking of the complete
`buffered_reader_test.cpp` translation unit against repository and
installed third-party headers with `-Werror -Wunused-member-function`.
- Passed repository clang-format 16 formatting/check scripts,
`build-support/check-build-hygiene.sh`, and `git diff --check`.
- Full BE build and runtime tests are **not verified locally**:
`./run-be-ut.sh --run --filter='BufferedReaderTest.*:CsvV2ReaderTest.*'
-j 8` stops at JDK initialization (`Failed setting boot class path`).
Separate production/CSV syntax attempts encounter installed C++ library
and stale generated-header incompatibilities. No compilation database is
available for clang-tidy. Runtime and sanitizer validation remain
pending CI.
- The S3 mock test disables file cache; the concurrent statistics test
exercises the actual prefetch implementation with a mock reader updating
`FileCacheStatistics`. No cached-S3 end-to-end runtime result is
claimed.
- TPC-H load benchmark: elapsed time decreased from 2617.33 s to 1696.06
s (35.2% less load time; approximately 1.54x speedup).
- Behavior changed:
- [x] Yes. Eligible S3 CSV/Text readers use sequential prefetch. Shared
prefetch readers isolate cache counters, drain outstanding reads during
cleanup and normalize ring capacity. CSV/Text publishes prefetch
profiles on close.
- Does this need documentation?
- [x] No.
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
---
.../delimited_text/delimited_text_reader.cpp | 18 +-
be/src/io/fs/buffered_reader.cpp | 43 +++--
be/src/io/fs/buffered_reader.h | 8 +
.../format_v2/delimited_text/csv_reader_test.cpp | 164 +++++++++++++++++++
be/test/io/fs/buffered_reader_test.cpp | 181 ++++++++++++++++++---
5 files changed, 373 insertions(+), 41 deletions(-)
diff --git a/be/src/format_v2/delimited_text/delimited_text_reader.cpp
b/be/src/format_v2/delimited_text/delimited_text_reader.cpp
index 049e7265639..7aa9dff1edf 100644
--- a/be/src/format_v2/delimited_text/delimited_text_reader.cpp
+++ b/be/src/format_v2/delimited_text/delimited_text_reader.cpp
@@ -37,6 +37,7 @@
#include "format_v2/column_mapper.h"
#include "format_v2/materialized_reader_util.h"
#include "io/file_factory.h"
+#include "io/fs/buffered_reader.h"
#include "io/fs/tracing_file_reader.h"
#include "runtime/descriptors.h"
#include "runtime/file_scan_profile.h"
@@ -486,6 +487,13 @@ Status DelimitedTextReader::close() {
_line_reader.reset();
}
_decompressor.reset();
+ // Stream pipes are shared with the load context; preserve their existing
ownership and
+ // cancellation behavior. Only filesystem readers enter the sequential
prefetch path.
+ if (_file_reader != nullptr && _scan_params->file_type !=
TFileType::FILE_STREAM) {
+ // Drain prefetch workers before publishing their counters or
releasing IOContext refs.
+ RETURN_IF_ERROR(_file_reader->close());
+ _file_reader->collect_profile_before_close();
+ }
_file_reader.reset();
_tracing_file_reader.reset();
_requested_columns.clear();
@@ -605,8 +613,14 @@ Status DelimitedTextReader::_open_file() {
} else {
auto reader_options =
FileFactory::get_reader_options(_runtime_state->query_options(),
*_file_description);
- auto file_reader = DORIS_TRY(FileFactory::create_file_reader(
- *_system_properties, *_file_description, reader_options,
_profile));
+ // Include the prefix used to align non-first splits. Unknown-size
first splits read to
+ // EOF; PrefetchBufferedReader bounds this range by the opened file's
physical size.
+ const auto prefetch_end = _size < 0 ?
std::numeric_limits<size_t>::max()
+ : cast_set<size_t>(_start_offset +
_size);
+ auto file_reader = DORIS_TRY(io::DelegateReader::create_file_reader(
+ _profile, *_system_properties, *_file_description,
reader_options,
+ io::DelegateReader::AccessMode::SEQUENTIAL, _io_ctx,
+ io::PrefetchRange(_start_offset, prefetch_end)));
_file_reader = _io_ctx && _io_ctx->file_reader_stats
?
std::make_shared<io::TracingFileReader>(std::move(file_reader),
_io_ctx->file_reader_stats)
diff --git a/be/src/io/fs/buffered_reader.cpp b/be/src/io/fs/buffered_reader.cpp
index 599507ad751..f86a681222f 100644
--- a/be/src/io/fs/buffered_reader.cpp
+++ b/be/src/io/fs/buffered_reader.cpp
@@ -413,6 +413,13 @@ void PrefetchBuffer::reset_offset(size_t offset) {
_prefetched.notify_all();
return;
}
+ _merge_file_cache_stats();
+ // Snapshot on the consumer thread, not concurrently with scanner
counter updates.
+ // Cached readers get per-buffer counters; uncached readers keep a
null stats pointer.
+ _prefetch_io_ctx = *_io_ctx;
+ if (_io_ctx->file_cache_stats != nullptr) {
+ _prefetch_io_ctx.file_cache_stats = &_file_cache_stats;
+ }
_buffer_status = BufferStatus::RESET;
_offset = offset;
_prefetched.notify_all();
@@ -470,7 +477,7 @@ void PrefetchBuffer::prefetch_buffer() {
{
SCOPED_RAW_TIMER(&_statis.read_time);
- s = _reader->read_at(_offset, Slice {_buf.data(), buf_size}, &_len,
_io_ctx);
+ s = _reader->read_at(_offset, Slice {_buf.data(), buf_size}, &_len,
&_prefetch_io_ctx);
}
if (UNLIKELY(s.ok() && buf_size != _len)) {
// This indicates that the data size returned by S3 object storage is
smaller than what we requested,
@@ -497,6 +504,7 @@ void PrefetchBuffer::prefetch_buffer() {
_offset, s.to_string());
_prefetch_status = std::move(s);
}
+ _has_unmerged_cache_stats = _prefetch_io_ctx.file_cache_stats != nullptr;
_buffer_status = BufferStatus::PREFETCHED;
_prefetched.notify_all();
// eof would come up with len == 0, it would be handled by read_buffer
@@ -582,6 +590,7 @@ Status PrefetchBuffer::read_buffer(size_t off, const char*
out, size_t buf_len,
if (UNLIKELY(BufferStatus::CLOSED == _buffer_status)) {
return Status::OK();
}
+ _merge_file_cache_stats();
}
RETURN_IF_ERROR(_prefetch_status);
// there is only parquet would do not sequence read
@@ -612,15 +621,23 @@ Status PrefetchBuffer::read_buffer(size_t off, const
char* out, size_t buf_len,
return Status::OK();
}
+void PrefetchBuffer::_merge_file_cache_stats() {
+ // Called only by the consumer with _lock held and no pending read. A
buffer may be
+ // consumed in several slices; publish each completed request exactly once.
+ if (_has_unmerged_cache_stats) {
+ _io_ctx->file_cache_stats->merge_from(_file_cache_stats);
+ _file_cache_stats = {};
+ _has_unmerged_cache_stats = false;
+ }
+}
+
void PrefetchBuffer::close() {
std::unique_lock lck {_lock};
- // in case _reader still tries to write to the buf after we close the
buffer
- if (!_prefetched.wait_for(lck,
-
std::chrono::milliseconds(config::buffered_reader_read_timeout_ms),
- [this]() { return _buffer_status !=
BufferStatus::PENDING; })) {
- _prefetch_status = Status::TimedOut("time out when close prefetch
buffer");
- return;
- }
+ // A foreground read timeout does not cancel the underlying I/O. Wait for
it to finish
+ // before releasing scanner-owned IOContext pointees, including cache
statistics and
+ // the cache-write limiter. Queued tasks observe CLOSED and do not start
another read.
+ _prefetched.wait(lck, [this]() { return _buffer_status !=
BufferStatus::PENDING; });
+ _merge_file_cache_stats();
_buffer_status = BufferStatus::CLOSED;
_prefetched.notify_all();
// Explicitly release the backing buffer here, in the calling (query)
thread which has a
@@ -647,14 +664,14 @@
PrefetchBufferedReader::PrefetchBufferedReader(RuntimeProfile* profile, io::File
}
_io_ctx = _io_ctx_holder.get();
if (buffer_size == -1L) {
- buffer_size = config::remote_storage_read_buffer_mb * 1024 * 1024;
+ buffer_size = int64_t {config::remote_storage_read_buffer_mb} * 1024 *
1024;
}
_size = _reader->size();
- _whole_pre_buffer_size = buffer_size;
+ // Use the allocated capacity as the ring modulus. For example, 6 MiB
allocates
+ // one 4 MiB buffer, so offset 4 MiB must wrap to slot 0, not index a
missing slot 1.
+ const int64_t buffer_num = std::max<int64_t>(1, buffer_size /
s_max_pre_buffer_size);
+ _whole_pre_buffer_size = buffer_num * s_max_pre_buffer_size;
_file_range.end_offset = std::min(_file_range.end_offset, _size);
- int buffer_num = buffer_size > s_max_pre_buffer_size
- ? cast_set<int>(buffer_size) /
cast_set<int>(s_max_pre_buffer_size)
- : 1;
std::function<void(PrefetchBuffer&)> sync_buffer = nullptr;
if (profile != nullptr) {
const char* prefetch_buffered_reader = "PrefetchBufferedReader";
diff --git a/be/src/io/fs/buffered_reader.h b/be/src/io/fs/buffered_reader.h
index 9b0e66ebbd0..ebe7b4ebe40 100644
--- a/be/src/io/fs/buffered_reader.h
+++ b/be/src/io/fs/buffered_reader.h
@@ -469,6 +469,11 @@ struct PrefetchBuffer :
std::enable_shared_from_this<PrefetchBuffer>, public Pro
io::FileReaderSPtr _reader;
std::shared_ptr<const IOContext> _io_ctx_holder;
const IOContext* _io_ctx = nullptr;
+ // Only the worker writes these statistics. The consumer merges them after
observing
+ // completion under _lock, before reusing or closing the buffer.
+ IOContext _prefetch_io_ctx;
+ FileCacheStatistics _file_cache_stats;
+ bool _has_unmerged_cache_stats = false;
PODArray<char> _buf;
BufferStatus _buffer_status {BufferStatus::RESET};
std::mutex _lock;
@@ -515,6 +520,9 @@ struct PrefetchBuffer :
std::enable_shared_from_this<PrefetchBuffer>, public Pro
void _collect_profile_at_runtime() override {}
void _collect_profile_before_close() override;
+
+private:
+ void _merge_file_cache_stats();
};
constexpr int64_t s_max_pre_buffer_size = 4 * 1024 * 1024; // 4MB
diff --git a/be/test/format_v2/delimited_text/csv_reader_test.cpp
b/be/test/format_v2/delimited_text/csv_reader_test.cpp
index 32d4255acee..8493b216e06 100644
--- a/be/test/format_v2/delimited_text/csv_reader_test.cpp
+++ b/be/test/format_v2/delimited_text/csv_reader_test.cpp
@@ -20,9 +20,12 @@
#include <gtest/gtest.h>
#include <algorithm>
+#include <atomic>
+#include <cstring>
#include <filesystem>
#include <fstream>
#include <memory>
+#include <thread>
#include "common/config.h"
#include "common/consts.h"
@@ -44,17 +47,95 @@
#include "exprs/vexpr_context.h"
#include "format/csv/csv_reader.h"
#include "format_v2/column_mapper.h"
+#include "io/fs/stream_load_pipe.h"
#include "io/io_common.h"
+#include "runtime/exec_env.h"
#include "runtime/runtime_profile.h"
#include "testutil/desc_tbl_builder.h"
#include "testutil/mock/mock_runtime_state.h"
+#include "testutil/mock/obj_storage_client_test_stub.h"
#include "testutil/scoped_temp_dir.h"
#include "util/debug_points.h"
#include "util/defer_op.h"
+#include "util/s3_util.h"
+#include "util/threadpool.h"
namespace doris::format::csv {
namespace {
+class CsvPrefetchS3Client : public io::ObjStorageClientTestStub {
+public:
+ const std::string data =
"id,name,score\n1,alice,10\n2,bob,20\n3,carol,30\n";
+ const std::thread::id scanner_thread = std::this_thread::get_id();
+ std::atomic<size_t> foreground_reads {0};
+ std::atomic<size_t> reads {0};
+
+ io::ObjStorageHeadResult head_object(const io::ObjStoragePath&) override {
+ return {.resp = io::ObjStorageResponse::OK(),
+ .file_size = static_cast<long long>(data.size())};
+ }
+
+ io::ObjStorageResponse get_object(const io::ObjStoragePath&, void* buffer,
size_t offset,
+ size_t bytes_read, size_t* size_return)
override {
+ ++reads;
+ if (std::this_thread::get_id() == scanner_thread) {
+ ++foreground_reads;
+ }
+ EXPECT_LE(offset + bytes_read, data.size());
+ *size_return = std::min(bytes_read, data.size() - offset);
+ std::memcpy(buffer, data.data() + offset, *size_return);
+ return io::ObjStorageResponse::OK();
+ }
+
+ // Only HEAD and GET belong to this read test. Explicit overrides avoid
generating unused
+ // gMock expectation helpers for this anonymous-namespace client.
+ io::ObjStorageUploadResult create_multipart_upload(const
io::ObjStoragePath&) override {
+ return {.resp = unexpected_operation()};
+ }
+
+ io::ObjStorageResponse put_object(const io::ObjStoragePath&,
std::string_view) override {
+ return unexpected_operation();
+ }
+
+ io::ObjStorageUploadResult upload_part(const io::ObjStoragePath&, const
std::string&,
+ std::string_view, int) override {
+ return {.resp = unexpected_operation()};
+ }
+
+ io::ObjStorageResponse complete_multipart_upload(
+ const io::ObjStoragePath&, const std::string&,
+ const std::vector<io::ObjStorageCompletedPart>&) override {
+ return unexpected_operation();
+ }
+
+ io::ObjStorageResponse delete_objects(const io::ObjStoragePath&,
+ std::vector<std::string>) override {
+ return unexpected_operation();
+ }
+
+ io::ObjStorageResponse delete_object(const io::ObjStoragePath&) override {
+ return unexpected_operation();
+ }
+
+ std::string generate_presigned_url(const io::ObjStoragePath&, int64_t)
override {
+ ADD_FAILURE() << "Unexpected signed URL request in CSV prefetch test";
+ return {};
+ }
+
+ io::ObjStorageListPageResult list_objects_page(const io::ObjStoragePath&,
+ std::string_view) override {
+ return {.resp = unexpected_operation()};
+ }
+
+private:
+ static io::ObjStorageResponse unexpected_operation() {
+ ADD_FAILURE() << "Unexpected object storage operation in CSV prefetch
test";
+ return {.status = {TStatusCode::NOT_IMPLEMENTED_ERROR,
+ "CSV prefetch test only supports HEAD and GET"},
+ .http_code = 0};
+ }
+};
+
TFileScanRangeParams csv_scan_params() {
TFileScanRangeParams params;
params.__set_format_type(TFileFormatType::FORMAT_CSV_PLAIN);
@@ -512,6 +593,78 @@ protected:
TFileScanRangeParams _params;
};
+TEST_F(CsvV2ReaderTest, S3PrefetchPreservesSplitRowsAndReadStatistics) {
+ auto* env = ExecEnv::GetInstance();
+ auto saved_pool = std::move(env->_buffered_reader_prefetch_thread_pool);
+ const auto saved_in_memory_size = config::in_memory_file_size;
+ Defer restore([&] {
+ env->_buffered_reader_prefetch_thread_pool.reset();
+ env->_buffered_reader_prefetch_thread_pool = std::move(saved_pool);
+ config::in_memory_file_size = saved_in_memory_size;
+ S3ClientFactory::instance().clear_client_creator_for_test();
+ });
+ ASSERT_TRUE(ThreadPoolBuilder("CsvPrefetchTest")
+ .set_min_threads(1)
+ .set_max_threads(1)
+ .build(&env->_buffered_reader_prefetch_thread_pool)
+ .ok());
+ // Keep the small fixture on the same prefetch path as a large S3 CSV file.
+ config::in_memory_file_size = 0;
+ auto client = std::make_shared<CsvPrefetchS3Client>();
+ S3ClientFactory::instance().set_client_creator_for_test(
+ [client](const S3ClientConf&) { return client; });
+ _params.__set_file_type(TFileType::FILE_S3);
+ _state._query_options.__set_enable_file_cache(false);
+
+ // Cover a complete file, a split starting inside Alice's row, and an
unknown-size first split.
+ for (const int64_t split_start : {0, 17, -1}) {
+ SCOPED_TRACE(split_start);
+ auto properties = std::make_shared<io::FileSystemProperties>();
+ properties->system_type = TFileType::FILE_S3;
+ properties->properties = {{"AWS_ENDPOINT",
"http://csv-prefetch.invalid"},
+ {"AWS_REGION", "test-region"}};
+ auto desc = std::make_unique<io::FileDescription>();
+ desc->path = "s3://csv-prefetch/reader.csv";
+ desc->range_start_offset = std::max<int64_t>(0, split_start);
+ desc->file_size = split_start < 0 ? -1 :
static_cast<int64_t>(client->data.size());
+ desc->range_size = desc->file_size - desc->range_start_offset;
+ io::FileReaderStats stats;
+ auto io_ctx = std::make_shared<io::IOContext>();
+ io_ctx->file_reader_stats = &stats;
+ auto reader =
+ std::make_unique<CsvReader>(properties, desc, io_ctx,
&_profile, &_params, _slots);
+ ASSERT_TRUE(reader->init(&_state).ok());
+ std::vector<ColumnDefinition> schema;
+ ASSERT_TRUE(reader->get_schema(&schema).ok());
+ auto request = std::make_shared<FileScanRequest>();
+ for (int32_t id = 0; id < 3; ++id) {
+ request->non_predicate_columns.push_back(
+ LocalColumnIndex::top_level(LocalColumnId(id)));
+ request->local_positions.emplace(LocalColumnId(id),
LocalIndex(id));
+ }
+ ASSERT_TRUE(reader->open(request).ok());
+ auto block = make_block(schema, {0, 1, 2});
+ size_t rows = 0;
+ bool eof = false;
+ ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+ ASSERT_EQ(rows, split_start > 0 ? 2 : 3);
+ EXPECT_EQ(nullable_int_at(*block.get_by_position(0).column, 0),
split_start > 0 ? 2 : 1);
+ EXPECT_EQ(nullable_string_at(*block.get_by_position(1).column, rows -
1), "carol");
+ EXPECT_GT(stats.read_calls, 0);
+ EXPECT_GT(stats.read_bytes, 0);
+ const auto previous_prefetch_io = counter_value(&_profile,
"PreRequestIO");
+ ASSERT_TRUE(reader->close().ok());
+ EXPECT_GT(counter_value(&_profile, "PreRequestIO"),
previous_prefetch_io);
+ EXPECT_GT(counter_value(&_profile, "PreRequestBytes"), 0);
+ EXPECT_GT(counter_value(&_profile, "ReadTime"), 0);
+ const auto collected_prefetch_io = counter_value(&_profile,
"PreRequestIO");
+ ASSERT_TRUE(reader->close().ok());
+ EXPECT_EQ(counter_value(&_profile, "PreRequestIO"),
collected_prefetch_io);
+ }
+ EXPECT_GT(client->reads.load(), 0);
+ EXPECT_EQ(client->foreground_reads.load(), 0);
+}
+
// Scenario: CSV v2 exposes FE-provided file slots as nullable file-local
schema using column_idxs
// as CSV field ordinals.
TEST_F(CsvV2ReaderTest, SchemaUsesSlotTypesAndColumnIdxs) {
@@ -927,6 +1080,17 @@ TEST_F(CsvV2ReaderTest,
StreamInputRequiresLoadIdBeforeOpeningPipe) {
<< status;
}
+TEST_F(CsvV2ReaderTest, ClosePreservesSharedStreamPipe) {
+ _params.__set_file_type(TFileType::FILE_STREAM);
+ auto reader = create_unknown_size_reader(_file_path, &_params, _slots,
&_state, &_profile);
+ auto pipe = std::make_shared<io::StreamLoadPipe>();
+ reader->_file_reader = pipe;
+ ASSERT_TRUE(reader->close().ok());
+ EXPECT_FALSE(pipe->closed());
+ reader.reset();
+ EXPECT_FALSE(pipe->closed());
+}
+
// Scenario: CSV has no footer row count, so v2 COUNT pushdown scans the split
and returns the
// counted row count through FileAggregateResult.
TEST_F(CsvV2ReaderTest, CountAggregateScansRows) {
diff --git a/be/test/io/fs/buffered_reader_test.cpp
b/be/test/io/fs/buffered_reader_test.cpp
index 75ed5e351dc..b8f96b339fc 100644
--- a/be/test/io/fs/buffered_reader_test.cpp
+++ b/be/test/io/fs/buffered_reader_test.cpp
@@ -20,9 +20,12 @@
#include <glog/logging.h>
#include <gtest/gtest-message.h>
#include <gtest/gtest-test-part.h>
+#include <gtest/gtest.h>
#include <limits.h>
+#include <algorithm>
#include <atomic>
+#include <cstring>
#include <memory>
#include <ostream>
#include <thread>
@@ -33,6 +36,7 @@
#include "io/fs/local_file_system.h"
#include "runtime/exec_env.h"
#include "util/countdown_latch.h"
+#include "util/defer_op.h"
#include "util/stopwatch.hpp"
#include "util/threadpool.h"
@@ -150,7 +154,7 @@ public:
protected:
Status read_at_impl(size_t offset, Slice result, size_t* bytes_read,
- const io::IOContext* /*io_ctx*/) override {
+ const io::IOContext* io_ctx) override {
_read_started->count_down();
_continue_read->wait();
if (offset >= _size) {
@@ -158,7 +162,9 @@ protected:
return Status::OK();
}
*bytes_read = std::min(_size - offset, result.size);
- return Status::TimedOut("injected prefetch timeout");
+ memset(result.data, 0, *bytes_read);
+ io_ctx->file_cache_stats->bytes_read_from_remote += *bytes_read;
+ return Status::OK();
}
private:
@@ -352,37 +358,160 @@ TEST_F(BufferedReaderTest, test_miss) {
EXPECT_EQ(45, bytes_read);
}
-TEST_F(BufferedReaderTest,
prefetch_task_keeps_reader_alive_after_close_timeout) {
+// A read timeout must not let close/destruction release the scanner's
borrowed state.
+TEST_F(BufferedReaderTest, prefetch_close_drains_timed_out_reads) {
const auto saved_timeout_ms = config::buffered_reader_read_timeout_ms;
- config::buffered_reader_read_timeout_ms = 50;
-
- CountDownLatch read_started(1);
- CountDownLatch continue_read(1);
- std::atomic<bool> reader_destroyed = false;
- auto inner_reader = std::make_shared<BlockingFileReader>(1024,
&read_started, &continue_read,
-
&reader_destroyed);
- auto weak_reader = std::weak_ptr<io::FileReader>(inner_reader);
- {
- io::PrefetchBufferedReader reader(nullptr, std::move(inner_reader),
- io::PrefetchRange(0, 1024));
+ Defer restore([&] { config::buffered_reader_read_timeout_ms =
saved_timeout_ms; });
+ config::buffered_reader_read_timeout_ms = 20;
+
+ for (bool explicit_close : {false, true}) {
+ SCOPED_TRACE(explicit_close);
+ CountDownLatch read_started(1);
+ CountDownLatch continue_read(1);
+ CountDownLatch close_started(1);
+ CountDownLatch close_finished(1);
+ std::atomic<bool> reader_destroyed = false;
+ io::FileCacheStatistics stats;
+ auto io_ctx = std::make_shared<io::IOContext>();
+ io_ctx->file_cache_stats = &stats;
+ auto inner_reader = std::make_shared<BlockingFileReader>(1024,
&read_started,
+
&continue_read, &reader_destroyed);
+ auto reader = std::make_unique<io::PrefetchBufferedReader>(
+ nullptr, std::move(inner_reader), io::PrefetchRange(0, 1024),
io_ctx);
uint8_t buf[1];
size_t bytes_read = 0;
- Status st = reader.read_at(0, Slice {buf, 1}, &bytes_read);
- EXPECT_TRUE(st.is<ErrorCode::TIMEOUT>());
- EXPECT_TRUE(read_started.wait_for(std::chrono::seconds(1)));
- EXPECT_FALSE(reader_destroyed.load());
+ EXPECT_TRUE(reader->read_at(0, Slice {buf, 1},
&bytes_read).is<ErrorCode::TIMEOUT>());
+ EXPECT_TRUE(read_started.wait_for(std::chrono::seconds(5)));
+ EXPECT_EQ(stats.bytes_read_from_remote, 0);
+
+ std::thread finish_read([&] {
+ close_started.wait();
+ // Hold the request beyond the old close timeout. Closing must
remain blocked.
+
EXPECT_FALSE(close_finished.wait_for(std::chrono::milliseconds(100)));
+ EXPECT_FALSE(reader_destroyed.load());
+ continue_read.count_down();
+ });
+ close_started.count_down();
+ if (explicit_close) {
+ EXPECT_TRUE(reader->close().ok());
+ }
+ reader.reset();
+ close_finished.count_down();
+ finish_read.join();
+ // Let the pool release its task captures before the
destructor-observer goes out of scope.
+ ExecEnv::GetInstance()->buffered_reader_prefetch_thread_pool()->wait();
+ EXPECT_TRUE(reader_destroyed.load());
+ EXPECT_EQ(stats.bytes_read_from_remote, 1024);
}
- EXPECT_FALSE(reader_destroyed.load());
- EXPECT_FALSE(weak_reader.expired());
+}
- continue_read.count_down();
- for (int i = 0; i < 100 && !reader_destroyed.load(); ++i) {
- std::this_thread::sleep_for(std::chrono::milliseconds(10));
+class PrefetchStatisticsFileReader : public MockOffsetFileReader {
+public:
+ PrefetchStatisticsFileReader(size_t size, io::FileCacheStatistics*
scanner_stats,
+ CountDownLatch* started, CountDownLatch*
resume)
+ : MockOffsetFileReader(size),
+ _scanner_stats(scanner_stats),
+ _started(started),
+ _resume(resume) {}
+
+ std::atomic<bool> used_scanner_stats = false;
+
+protected:
+ Status read_at_impl(size_t offset, Slice result, size_t* bytes_read,
+ const io::IOContext* io_ctx) override {
+ if (io_ctx->file_cache_stats == _scanner_stats) {
+ used_scanner_stats = true;
+ }
+ RETURN_IF_ERROR(MockOffsetFileReader::read_at_impl(offset, result,
bytes_read, io_ctx));
+ auto* stats = io_ctx->file_cache_stats;
+ stats->bytes_read_from_remote += *bytes_read;
+ ++stats->num_remote_io_total;
+ stats->peer_hosts.insert("prefetch-test");
+ stats->remote_only_on_miss_triggered = true;
+ stats->remote_only_on_miss_threshold_bytes = 42;
+ _started->count_down();
+ _resume->wait();
+ return Status::OK();
}
- EXPECT_TRUE(reader_destroyed.load());
- EXPECT_TRUE(weak_reader.expired());
- config::buffered_reader_read_timeout_ms = saved_timeout_ms;
+private:
+ io::FileCacheStatistics* _scanner_stats;
+ CountDownLatch* _started;
+ CountDownLatch* _resume;
+};
+
+TEST_F(BufferedReaderTest,
prefetch_merges_worker_cache_statistics_on_consumer) {
+ constexpr size_t buffer_size = 4 * 1024 * 1024;
+ constexpr size_t file_size = 5 * buffer_size;
+ CountDownLatch started(4);
+ CountDownLatch resume(1);
+ io::FileCacheStatistics stats;
+ auto io_ctx = std::make_shared<io::IOContext>();
+ io_ctx->file_cache_stats = &stats;
+ auto inner =
+ std::make_shared<PrefetchStatisticsFileReader>(file_size, &stats,
&started, &resume);
+ io::PrefetchBufferedReader reader(nullptr, inner, io::PrefetchRange(0,
file_size), io_ctx,
+ 4 * buffer_size);
+ std::thread release_workers([&] {
+ EXPECT_TRUE(started.wait_for(std::chrono::seconds(5)));
+ // All four workers have updated their counters, but the consumer has
not merged them.
+ EXPECT_FALSE(inner->used_scanner_stats.load());
+ EXPECT_EQ(stats.bytes_read_from_remote, 0);
+ resume.count_down();
+ });
+ uint8_t buf[2];
+ size_t bytes_read = 0;
+ auto status = reader.read_at(0, Slice {buf, 1}, &bytes_read);
+ release_workers.join();
+ ASSERT_TRUE(status.ok());
+ EXPECT_EQ(stats.bytes_read_from_remote, buffer_size);
+ ASSERT_TRUE(reader.read_at(1, Slice {buf, 1}, &bytes_read).ok());
+ EXPECT_EQ(stats.bytes_read_from_remote, buffer_size); // No double merge
for another slice.
+ // Reuse slot 0 without consuming slots 1..3; close must still collect
their statistics.
+ ASSERT_TRUE(reader.read_at(4 * buffer_size, Slice {buf, 1},
&bytes_read).ok());
+ EXPECT_EQ(stats.bytes_read_from_remote, 2 * buffer_size);
+ ASSERT_TRUE(reader.close().ok());
+ EXPECT_EQ(stats.bytes_read_from_remote, file_size);
+ EXPECT_EQ(stats.num_remote_io_total, 5);
+ EXPECT_EQ(stats.peer_hosts.count("prefetch-test"), 1);
+ EXPECT_TRUE(stats.remote_only_on_miss_triggered);
+ EXPECT_EQ(stats.remote_only_on_miss_threshold_bytes, 42);
+ ASSERT_TRUE(reader.close().ok());
+ EXPECT_EQ(stats.bytes_read_from_remote, file_size);
+}
+
+TEST_F(BufferedReaderTest, prefetch_normalizes_ring_capacity) {
+ constexpr size_t mb = 1024 * 1024;
+ constexpr size_t file_size = 10 * mb;
+ std::vector<uint8_t> expected(file_size);
+ for (size_t i = 0; i < file_size; ++i) {
+ expected[i] = i % UCHAR_MAX;
+ }
+ const auto saved_buffer_mb = config::remote_storage_read_buffer_mb;
+ Defer restore([&] { config::remote_storage_read_buffer_mb =
saved_buffer_mb; });
+ // Includes less than one buffer, a non-multiple of 4 MiB, and a
multi-slot ring.
+ for (int capacity_mb : {0, 1, 6, 10, 16}) {
+ config::remote_storage_read_buffer_mb = capacity_mb;
+ for (size_t start : {size_t {0}, 4 * mb + 7}) {
+ SCOPED_TRACE(capacity_mb);
+ SCOPED_TRACE(start);
+ io::PrefetchBufferedReader reader(nullptr,
+
std::make_shared<MockOffsetFileReader>(file_size),
+ io::PrefetchRange(start,
file_size));
+ std::vector<uint8_t> data(3 * mb);
+ for (size_t offset = start; offset < file_size;) {
+ size_t bytes_read = 0;
+ ASSERT_TRUE(
+ reader.read_at(offset, Slice {data.data(),
data.size()}, &bytes_read).ok());
+ ASSERT_EQ(bytes_read, std::min(data.size(), file_size -
offset));
+ ASSERT_TRUE(std::equal(data.begin(), data.begin() + bytes_read,
+ expected.begin() + offset))
+ << offset;
+ offset += bytes_read;
+ }
+ ASSERT_TRUE(reader.close().ok());
+ }
+ }
}
TEST_F(BufferedReaderTest, test_read_amplify) {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]