This is an automated email from the ASF dual-hosted git repository.
ColinLeeo pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/tsfile.git
The following commit(s) were added to refs/heads/develop by this push:
new 7a7d7f96e perf(cpp): push down multi-value aligned offsets (#863)
7a7d7f96e is described below
commit 7a7d7f96e1015de2774ec936f498df4e05bce293
Author: Colin Lee <[email protected]>
AuthorDate: Tue Jul 21 09:46:26 2026 +0800
perf(cpp): push down multi-value aligned offsets (#863)
---
cpp/src/reader/aligned_chunk_reader.cc | 140 +++++--
cpp/src/reader/aligned_chunk_reader.h | 21 +-
.../reader/block/single_device_tsblock_reader.cc | 64 ++-
.../reader/block/single_device_tsblock_reader.h | 12 +-
cpp/src/reader/chunk_reader.cc | 21 +-
cpp/src/reader/chunk_reader.h | 3 +-
cpp/src/reader/tsfile_series_scan_iterator.cc | 60 ++-
cpp/src/reader/tsfile_series_scan_iterator.h | 8 +
cpp/test/reader/tsfile_reader_test.cc | 461 ++++++++++++++++++++-
9 files changed, 697 insertions(+), 93 deletions(-)
diff --git a/cpp/src/reader/aligned_chunk_reader.cc
b/cpp/src/reader/aligned_chunk_reader.cc
index f02497363..4b89e659b 100644
--- a/cpp/src/reader/aligned_chunk_reader.cc
+++ b/cpp/src/reader/aligned_chunk_reader.cc
@@ -333,7 +333,9 @@ int AlignedChunkReader::alloc_compressor_and_decoder(
int AlignedChunkReader::get_next_page(TsBlock* ret_tsblock,
Filter* oneshoot_filter, PageArena& pa) {
if (multi_value_mode_) {
- return get_next_page_multi(ret_tsblock, oneshoot_filter, pa);
+ int row_offset = 0;
+ return get_next_page_multi(ret_tsblock, oneshoot_filter, pa,
+ row_offset);
}
int ret = E_OK;
Filter* filter =
@@ -353,7 +355,7 @@ int AlignedChunkReader::get_next_page(TsBlock* ret_tsblock,
value_chunk_meta_, value_in_stream_,
cur_value_page_header_, value_chunk_visit_offset_,
value_chunk_header_))) {
- } else if (cur_page_statisify_filter(filter)) {
+ } else if (cur_page_may_satisfy_filter(filter)) {
break;
} else if (RET_FAIL(skip_cur_page())) {
}
@@ -467,7 +469,10 @@ int AlignedChunkReader::read_from_file_and_rewrap(
return ret;
}
-bool AlignedChunkReader::cur_page_statisify_filter(Filter* filter) {
+// Page statistics provide two levels of certainty: "may satisfy" means the
+// page cannot be ruled out, while "fully satisfies" means every row is known
+// to match and the page count can safely be used for offset pushdown.
+bool AlignedChunkReader::cur_page_may_satisfy_filter(Filter* filter) {
bool value_satisfy = filter == nullptr ||
cur_value_page_header_.statistic_ == nullptr ||
filter->satisfy(cur_value_page_header_.statistic_);
@@ -477,6 +482,16 @@ bool AlignedChunkReader::cur_page_statisify_filter(Filter*
filter) {
return time_satisfy && value_satisfy;
}
+bool AlignedChunkReader::cur_page_fully_satisfies_filter(Filter* filter) {
+ Statistic* stat = cur_time_page_header_.statistic_;
+ if (stat == nullptr) {
+ stat = cur_value_page_header_.statistic_;
+ }
+ return filter == nullptr ||
+ (stat != nullptr &&
+ filter->contain_start_end_time(stat->start_time_,
stat->end_time_));
+}
+
int AlignedChunkReader::skip_cur_page() {
int ret = E_OK;
// visit a page tv data
@@ -1082,17 +1097,19 @@ int AlignedChunkReader::get_next_page(TsBlock*
ret_tsblock,
int64_t min_time_hint, int& row_offset,
int& row_limit) {
if (multi_value_mode_) {
- // Multi-value aligned path doesn't yet honour row_offset / row_limit
- // / min_time_hint — they get dropped on the floor, which silently
- // returns full chunk data when the caller asked for a sub-range.
- // Refuse the combination so the caller sees an actual error instead
- // of garbage results. set_row_range(0, -1) keeps the all-rows
- // contract intact for normal queries.
- if (row_offset > 0 || row_limit >= 0 ||
+ if (row_limit == 0) {
+ return E_NO_MORE_DATA;
+ }
+ // The multi-value path can consume offset through chunk/page count
+ // statistics. Limit and min-time pushdown still need separate state
+ // handling, so keep those combinations explicit instead of silently
+ // returning a wider range.
+ if (row_limit >= 0 ||
min_time_hint != std::numeric_limits<int64_t>::min()) {
return common::E_NOT_SUPPORT;
}
- return get_next_page_multi(ret_tsblock, oneshoot_filter, pa);
+ return get_next_page_multi(ret_tsblock, oneshoot_filter, pa,
+ row_offset);
}
int ret = E_OK;
Filter* filter =
@@ -1118,13 +1135,14 @@ int AlignedChunkReader::get_next_page(TsBlock*
ret_tsblock,
value_chunk_meta_, value_in_stream_,
cur_value_page_header_, value_chunk_visit_offset_,
value_chunk_header_))) {
- } else if (!cur_page_statisify_filter(filter)) {
+ } else if (!cur_page_may_satisfy_filter(filter)) {
if (RET_FAIL(skip_cur_page())) {
}
} else if (should_skip_page_by_time(min_time_hint)) {
if (RET_FAIL(skip_cur_page())) {
}
- } else if (should_skip_page_by_offset(row_offset)) {
+ } else if (cur_page_fully_satisfies_filter(filter) &&
+ should_skip_page_by_offset(row_offset)) {
if (RET_FAIL(skip_cur_page())) {
}
} else {
@@ -1383,7 +1401,7 @@ int AlignedChunkReader::decode_time_page_with(const
ChunkPageInfo& page_info,
return ret;
}
-int AlignedChunkReader::build_page_plan(Filter* filter) {
+int AlignedChunkReader::build_page_plan(Filter* filter, int& row_offset) {
int ret = E_OK;
chunk_pages_.clear();
current_page_plan_index_ = 0;
@@ -1459,6 +1477,13 @@ int AlignedChunkReader::build_page_plan(Filter* filter) {
int32_t last = -1;
for (int32_t i = 0; i < static_cast<int32_t>(times.size()); i++) {
if (filter->satisfy_start_end_time(times[i], times[i])) {
+ // Offset is defined on the filtered row stream. Consume
+ // matching rows only; holes inside a boundary page do not
+ // count toward it.
+ if (row_offset > 0) {
+ row_offset--;
+ continue;
+ }
if (first < 0) first = i;
last = i;
}
@@ -1479,6 +1504,18 @@ int AlignedChunkReader::build_page_plan(Filter* filter) {
}
page_info.row_end = static_cast<int32_t>(times.size());
}
+ if (page_info.pass_type == PagePassType::FULL_PASS &&
+ row_offset > 0) {
+ const int32_t page_row_count =
+ page_info.row_end - page_info.row_begin;
+ const int32_t rows_to_skip =
+ std::min(page_row_count, row_offset);
+ page_info.row_begin += rows_to_skip;
+ row_offset -= rows_to_skip;
+ if (page_info.row_begin == page_info.row_end) {
+ page_info.pass_type = PagePassType::SKIP;
+ }
+ }
if (page_info.row_begin < page_info.row_end) {
chunk_pages_.push_back(std::move(page_info));
}
@@ -1833,34 +1870,36 @@ void AlignedChunkReader::release_page_slot(size_t
page_idx) {
int AlignedChunkReader::get_next_page_multi(TsBlock* ret_tsblock,
Filter* oneshoot_filter,
- PageArena& pa) {
+ PageArena& pa, int& row_offset) {
int ret = E_OK;
Filter* filter =
(oneshoot_filter != nullptr ? oneshoot_filter : time_filter_);
// Dispatch:
- // - Multi-column with a thread pool → chunk-level pre-decode: one task
- // per value column decodes that column's whole chunk up front, then
the
- // scatter loop bulk-memcpys. decode_all_planned_pages() works for any
- // column count. (An earlier cutoff sent >6 columns down the serial
- // path because per_page_state — the upfront predecode buffer — grows
- // with column count and was feared to thrash cache; it still grows, so
- // very wide aligned chunks are the case to watch if reads regress.)
- // - Single column, or no thread pool → serial path: decode the current
- // page's columns inline (multi_DECODE_TV_BATCH), no thread-pool
- // fan-out.
+ // - Offset pushdown always uses the page plan so fully covered pages and
+ // the prefix of the first retained page can be removed before value
+ // decoding, even when no worker pool is available.
+ // - Multi-column with a thread pool also uses the page plan for
+ // chunk-level parallel pre-decode.
+ // - Otherwise decode the current page's columns inline.
#ifdef ENABLE_THREADS
- const bool use_chunk_level =
+ const bool use_parallel_page_plan =
decode_pool_ != nullptr && value_columns_.size() > 1;
#else
- const bool use_chunk_level = false;
+ const bool use_parallel_page_plan = false;
#endif
- if (!use_chunk_level) {
- return get_next_page_multi_serial(ret_tsblock, filter, pa);
+ const bool serial_page_in_progress =
+ !page_plan_built_ &&
+ (prev_time_page_not_finish() ||
prev_any_value_page_not_finish_multi());
+ const bool use_page_plan =
+ page_plan_built_ || (!serial_page_in_progress &&
+ (row_offset > 0 || use_parallel_page_plan));
+ if (!use_page_plan) {
+ return get_next_page_multi_serial(ret_tsblock, filter, pa, row_offset);
}
if (!page_plan_built_) {
- if (RET_FAIL(build_page_plan(filter))) {
+ if (RET_FAIL(build_page_plan(filter, row_offset))) {
return ret;
}
if (RET_FAIL(decode_all_planned_pages())) {
@@ -2044,7 +2083,8 @@ int AlignedChunkReader::get_next_page_multi(TsBlock*
ret_tsblock,
int AlignedChunkReader::get_next_page_multi_serial(TsBlock* ret_tsblock,
Filter* filter,
- PageArena& pa) {
+ PageArena& pa,
+ int& row_offset) {
int ret = E_OK;
bool pt = prev_time_page_not_finish();
bool pv = prev_any_value_page_not_finish_multi();
@@ -2069,8 +2109,14 @@ int
AlignedChunkReader::get_next_page_multi_serial(TsBlock* ret_tsblock,
}
}
if (IS_FAIL(ret)) break;
- if (cur_page_statisify_filter_multi(filter)) break;
- if (RET_FAIL(skip_cur_page_multi())) break;
+ if (!cur_page_may_satisfy_filter_multi(filter)) {
+ if (RET_FAIL(skip_cur_page_multi())) break;
+ } else if (cur_page_fully_satisfies_filter_multi(filter) &&
+ should_skip_page_by_offset_multi(row_offset)) {
+ if (RET_FAIL(skip_cur_page_multi())) break;
+ } else {
+ break;
+ }
if (!has_more_data()) {
ret = E_NO_MORE_DATA;
break;
@@ -2088,13 +2134,39 @@ int
AlignedChunkReader::get_next_page_multi_serial(TsBlock* ret_tsblock,
return ret;
}
-bool AlignedChunkReader::cur_page_statisify_filter_multi(Filter* filter) {
+// Keep the same conservative distinction as the single-value path: a page
+// that may satisfy the filter still needs row-level filtering unless its full
+// time range is covered by the filter.
+bool AlignedChunkReader::cur_page_may_satisfy_filter_multi(Filter* filter) {
bool time_satisfy = filter == nullptr ||
cur_time_page_header_.statistic_ == nullptr ||
filter->satisfy(cur_time_page_header_.statistic_);
return time_satisfy;
}
+bool AlignedChunkReader::cur_page_fully_satisfies_filter_multi(Filter* filter)
{
+ Statistic* stat = cur_time_page_header_.statistic_;
+ return filter == nullptr ||
+ (stat != nullptr &&
+ filter->contain_start_end_time(stat->start_time_,
stat->end_time_));
+}
+
+bool AlignedChunkReader::should_skip_page_by_offset_multi(int& row_offset) {
+ if (row_offset <= 0) {
+ return false;
+ }
+ Statistic* stat = cur_time_page_header_.statistic_;
+ if (stat == nullptr || stat->count_ == 0) {
+ return false;
+ }
+ int32_t count = stat->count_;
+ if (row_offset >= count) {
+ row_offset -= count;
+ return true;
+ }
+ return false;
+}
+
int AlignedChunkReader::skip_cur_page_multi() {
time_chunk_visit_offset_ += cur_time_page_header_.compressed_size_;
time_in_stream_.wrapped_buf_advance_read_pos(
diff --git a/cpp/src/reader/aligned_chunk_reader.h
b/cpp/src/reader/aligned_chunk_reader.h
index b92c4d7b5..45dd2ef99 100644
--- a/cpp/src/reader/aligned_chunk_reader.h
+++ b/cpp/src/reader/aligned_chunk_reader.h
@@ -36,7 +36,7 @@ class ThreadPool;
namespace storage {
-// Page classification for chunk-level parallel decode.
+// Page classification for chunk-level planned decode.
enum class PagePassType { SKIP, FULL_PASS, BOUNDARY };
// Metadata collected per page during the chunk scan phase.
@@ -53,8 +53,9 @@ struct ChunkPageInfo {
std::vector<uint32_t> value_uncompressed_sizes;
};
-// Decoded state for one (column, page) slot. Populated by chunk-level
-// parallel decode; consumed by the scatter loop.
+// Decoded state for one (column, page) slot. Populated eagerly by parallel
+// decode or lazily by the single-thread page-plan path, then consumed by the
+// scatter loop.
struct PageDecodedState {
std::vector<uint8_t> notnull_bitmap;
std::vector<char> predecoded_values;
@@ -194,7 +195,8 @@ class AlignedChunkReader : public IChunkReader {
uint32_t& chunk_visit_offset,
int32_t& file_data_buf_size,
int want_size = 0, bool may_shrink = true);
- bool cur_page_statisify_filter(Filter* filter);
+ bool cur_page_may_satisfy_filter(Filter* filter);
+ bool cur_page_fully_satisfies_filter(Filter* filter);
int skip_cur_page();
int decode_cur_time_page_data();
int decode_cur_value_page_data();
@@ -238,11 +240,14 @@ class AlignedChunkReader : public IChunkReader {
bool has_more_data_multi() const;
bool prev_any_value_page_not_finish_multi() const;
int get_next_page_multi(common::TsBlock* ret_tsblock,
- Filter* oneshoot_filter, common::PageArena& pa);
+ Filter* oneshoot_filter, common::PageArena& pa,
+ int& row_offset);
int get_next_page_multi_serial(common::TsBlock* ret_tsblock, Filter*
filter,
- common::PageArena& pa);
+ common::PageArena& pa, int& row_offset);
int skip_cur_page_multi();
- bool cur_page_statisify_filter_multi(Filter* filter);
+ bool cur_page_may_satisfy_filter_multi(Filter* filter);
+ bool cur_page_fully_satisfies_filter_multi(Filter* filter);
+ bool should_skip_page_by_offset_multi(int& row_offset);
int decode_cur_value_pages_multi();
int decode_cur_value_page_data_for(ValueColumnState& col);
int ensure_value_page_loaded(ValueColumnState& col);
@@ -255,7 +260,7 @@ class AlignedChunkReader : public IChunkReader {
int multi_DECODE_TV_BATCH(common::TsBlock* ret_tsblock,
common::RowAppender& row_appender, Filter*
filter,
common::PageArena* pa);
- int build_page_plan(Filter* filter);
+ int build_page_plan(Filter* filter, int& row_offset);
int decode_time_page_direct(const ChunkPageInfo& page_info,
std::vector<int64_t>& out_times);
int decode_time_page_with(const ChunkPageInfo& page_info,
diff --git a/cpp/src/reader/block/single_device_tsblock_reader.cc
b/cpp/src/reader/block/single_device_tsblock_reader.cc
index d842a35d9..5d66bb7e9 100644
--- a/cpp/src/reader/block/single_device_tsblock_reader.cc
+++ b/cpp/src/reader/block/single_device_tsblock_reader.cc
@@ -47,6 +47,7 @@ int SingleDeviceTsBlockReader::init(DeviceQueryTask*
device_query_task,
remaining_offset_ = 0;
remaining_limit_ = -1;
dense_row_count_ = -1;
+ row_offset_pushed_to_ssi_ = false;
return init_internal(device_query_task, block_size, time_filter,
field_filter);
}
@@ -58,6 +59,7 @@ int SingleDeviceTsBlockReader::init(DeviceQueryTask*
device_query_task,
remaining_offset_ = row_offset;
remaining_limit_ = row_limit;
dense_row_count_ = -1;
+ row_offset_pushed_to_ssi_ = false;
return init_internal(device_query_task, block_size, time_filter,
field_filter);
}
@@ -162,15 +164,6 @@ int
SingleDeviceTsBlockReader::init_internal(DeviceQueryTask* device_query_task,
}
}
time_column_index_ = 0;
- if (RET_FAIL(common::TsBlock::create_tsblock(&tuple_desc_, current_block_,
- block_size))) {
- return ret;
- }
- col_appenders_.resize(tuple_desc_.get_column_count());
- for (uint32_t i = 0; i < tuple_desc_.get_column_count(); i++) {
- col_appenders_[i] = new common::ColAppender(i, current_block_);
- }
- row_appender_ = new common::RowAppender(current_block_);
std::vector<ITimeseriesIndex*> time_series_indexs(
device_query_task_->get_column_mapping()
->get_measurement_columns()
@@ -182,6 +175,11 @@ int
SingleDeviceTsBlockReader::init_internal(DeviceQueryTask* device_query_task,
return ret;
}
dense_row_count_ = compute_dense_row_count(time_series_indexs);
+ if (time_filter == nullptr && field_filter == nullptr &&
+ dense_row_count_ >= 0 && remaining_offset_ >= dense_row_count_) {
+ remaining_offset_ -= dense_row_count_;
+ return common::E_OK;
+ }
// Early device-level time skip: if time_filter is set and ALL chunks of
// this device have statistics that fall outside the filter range, skip the
// entire device. Chunks without statistics are assumed to satisfy.
@@ -217,8 +215,6 @@ int
SingleDeviceTsBlockReader::init_internal(DeviceQueryTask* device_query_task,
}
if (examined_any && all_outside) {
// No data in this device matches the time filter.
- delete current_block_;
- current_block_ = nullptr;
return common::E_OK;
}
}
@@ -278,8 +274,20 @@ int
SingleDeviceTsBlockReader::init_internal(DeviceQueryTask* device_query_task,
}
auto* ctx = new VectorMeasurementColumnContext(tsfile_io_reader_);
+ const int ssi_offset =
+ (time_filter == nullptr && field_filter == nullptr &&
+ dense_row_count_ >= 0)
+ ? remaining_offset_
+ : 0;
if (common::E_OK == ctx->init(device_query_task_, meas_names,
- time_filter, pos_list, pa_)) {
+ time_filter, pos_list, pa_,
+ ssi_offset, -1)) {
+ if (ssi_offset > 0) {
+ row_offset_pushed_to_ssi_ = true;
+ // init() prefetches the first TsBlock and may consume part
+ // of the offset by skipping whole chunks/pages.
+ remaining_offset_ = ctx->get_ssi_row_offset();
+ }
// The shared ctx is referenced from N map entries; close()
// and the merge loop dedupe by pointer (already in place).
for (const auto& name : meas_names) {
@@ -326,8 +334,6 @@ int
SingleDeviceTsBlockReader::init_internal(DeviceQueryTask* device_query_task,
}
}
if (any_value_column_requested) {
- delete current_block_;
- current_block_ = nullptr;
return common::E_OK;
}
@@ -348,8 +354,6 @@ int
SingleDeviceTsBlockReader::init_internal(DeviceQueryTask* device_query_task,
// propagate so the caller sees the actual failure instead of
// an empty resultset wearing E_OK.
if (time_only_ret != common::E_NO_MORE_DATA) {
- delete current_block_;
- current_block_ = nullptr;
return time_only_ret;
}
}
@@ -373,11 +377,23 @@ int
SingleDeviceTsBlockReader::init_internal(DeviceQueryTask* device_query_task,
}
if (field_column_contexts_.empty()) {
- delete current_block_;
- current_block_ = nullptr;
return common::E_OK;
}
+ // Metadata and context initialization above can prove that a device has no
+ // rows to return. Delay result-buffer allocation until those early exits
+ // have been ruled out, so skipped devices never create appenders that
+ // briefly point at a deleted TsBlock.
+ if (RET_FAIL(common::TsBlock::create_tsblock(&tuple_desc_, current_block_,
+ block_size))) {
+ return ret;
+ }
+ col_appenders_.resize(tuple_desc_.get_column_count());
+ for (uint32_t i = 0; i < tuple_desc_.get_column_count(); i++) {
+ col_appenders_[i] = new common::ColAppender(i, current_block_);
+ }
+ row_appender_ = new common::RowAppender(current_block_);
+
for (const auto& id_column :
device_query_task->get_column_mapping()->get_id_columns()) {
const auto& column_pos_in_result =
@@ -540,7 +556,11 @@ int SingleDeviceTsBlockReader::has_next_aligned(bool&
result_has_next) {
int sr = ctx->skip_rows(skip);
if (sr != common::E_OK) return sr;
}
- remaining_offset_ -= skip;
+ if (row_offset_pushed_to_ssi_ && !aligned_vec_.empty()) {
+ remaining_offset_ = aligned_vec_[0]->get_ssi_row_offset();
+ } else {
+ remaining_offset_ -= skip;
+ }
continue;
}
@@ -973,6 +993,7 @@ int SingleMeasurementColumnContext::skip_rows(uint32_t
count) {
const uint32_t val_elem_size = common::get_data_type_size(dt);
value_iter_->advance(to_skip, val_elem_size);
}
+ consume_ssi_row_offset(static_cast<int>(to_skip));
if (time_iter_->end()) {
// Propagate hard errors from the next-tsblock load; E_NO_MORE_DATA
// is the legitimate end-of-stream signal and gets squashed back to
@@ -1004,7 +1025,8 @@
VectorMeasurementColumnContext::~VectorMeasurementColumnContext() {
int VectorMeasurementColumnContext::init(
DeviceQueryTask* device_query_task,
const std::vector<std::string>& measurement_names, Filter* time_filter,
- std::vector<std::vector<int32_t>>& pos_in_result, common::PageArena& pa) {
+ std::vector<std::vector<int32_t>>& pos_in_result, common::PageArena& pa,
+ int ssi_offset, int ssi_limit) {
int ret = common::E_OK;
pos_in_result_ = pos_in_result;
column_names_ = measurement_names;
@@ -1013,6 +1035,7 @@ int VectorMeasurementColumnContext::init(
time_filter))) {
return ret;
}
+ ssi_->set_row_range(ssi_offset, ssi_limit);
if (RET_FAIL(get_next_tsblock(true))) {
return ret;
}
@@ -1224,6 +1247,7 @@ int VectorMeasurementColumnContext::skip_rows(uint32_t
count) {
}
}
}
+ consume_ssi_row_offset(static_cast<int>(to_skip));
if (time_iter_->end()) {
int r = get_next_tsblock(false);
if (r != common::E_OK && r != common::E_NO_MORE_DATA) return r;
diff --git a/cpp/src/reader/block/single_device_tsblock_reader.h
b/cpp/src/reader/block/single_device_tsblock_reader.h
index e74304baf..4bf0cca0f 100644
--- a/cpp/src/reader/block/single_device_tsblock_reader.h
+++ b/cpp/src/reader/block/single_device_tsblock_reader.h
@@ -74,7 +74,7 @@ class SingleDeviceTsBlockReader : public TsBlockReader {
uint32_t block_size_;
common::TsBlock* current_block_ = nullptr;
std::vector<common::ColAppender*> col_appenders_;
- common::RowAppender* row_appender_;
+ common::RowAppender* row_appender_ = nullptr;
common::TupleDesc tuple_desc_;
bool last_block_returned_ = true;
std::map<std::string, MeasurementColumnContext*> field_column_contexts_;
@@ -86,6 +86,7 @@ class SingleDeviceTsBlockReader : public TsBlockReader {
int remaining_offset_ = 0;
int remaining_limit_ = -1;
int32_t dense_row_count_ = -1;
+ bool row_offset_pushed_to_ssi_ = false;
// Populated in init() when every field column comes from an aligned chunk.
// Provides cache-friendly vector iteration for has_next_aligned().
bool all_aligned_ = false;
@@ -114,14 +115,11 @@ class MeasurementColumnContext {
virtual int move_iter() = 0;
- virtual void set_ssi_row_range(int offset, int limit) {
- if (ssi_) ssi_->set_row_range(offset, limit);
- }
virtual int get_ssi_row_offset() const {
return ssi_ ? ssi_->get_row_offset() : 0;
}
- virtual int get_ssi_row_limit() const {
- return ssi_ ? ssi_->get_row_limit() : -1;
+ virtual void consume_ssi_row_offset(int count) {
+ if (ssi_) ssi_->consume_row_offset(count);
}
virtual uint32_t available_rows() const = 0;
@@ -195,7 +193,7 @@ class VectorMeasurementColumnContext final : public
MeasurementColumnContext {
const std::vector<std::string>& measurement_names,
Filter* time_filter,
std::vector<std::vector<int32_t>>& pos_in_result,
- common::PageArena& pa);
+ common::PageArena& pa, int ssi_offset = 0, int ssi_limit = -1);
int get_next_tsblock(bool alloc_mem) override;
int get_current_time(int64_t& time) override;
int get_current_value(char*& value, uint32_t& len) override;
diff --git a/cpp/src/reader/chunk_reader.cc b/cpp/src/reader/chunk_reader.cc
index 7c36ea07f..6b3d853d9 100644
--- a/cpp/src/reader/chunk_reader.cc
+++ b/cpp/src/reader/chunk_reader.cc
@@ -192,7 +192,7 @@ int ChunkReader::get_next_page(TsBlock* ret_tsblock,
Filter* oneshoot_filter,
return E_NO_MORE_DATA;
}
if (RET_FAIL(get_cur_page_header())) {
- } else if (cur_page_statisify_filter(filter)) {
+ } else if (cur_page_may_satisfy_filter(filter)) {
break;
} else if (RET_FAIL(skip_cur_page())) {
}
@@ -265,11 +265,21 @@ int ChunkReader::read_from_file_and_rewrap(int want_size)
{
return ret;
}
-bool ChunkReader::cur_page_statisify_filter(Filter* filter) {
+// Page statistics provide two levels of certainty: "may satisfy" means the
+// page cannot be ruled out, while "fully satisfies" means every row is known
+// to match and the page count can safely be used for offset pushdown.
+bool ChunkReader::cur_page_may_satisfy_filter(Filter* filter) {
return filter == nullptr || cur_page_header_.statistic_ == nullptr ||
filter->satisfy(cur_page_header_.statistic_);
}
+bool ChunkReader::cur_page_fully_satisfies_filter(Filter* filter) {
+ return filter == nullptr || (cur_page_header_.statistic_ != nullptr &&
+ filter->contain_start_end_time(
+ cur_page_header_.statistic_->start_time_,
+ cur_page_header_.statistic_->end_time_));
+}
+
int ChunkReader::skip_cur_page() {
int ret = E_OK;
// visit a page tv data
@@ -904,13 +914,14 @@ int ChunkReader::get_next_page(TsBlock* ret_tsblock,
Filter* oneshoot_filter,
return E_NO_MORE_DATA;
}
if (RET_FAIL(get_cur_page_header())) {
- } else if (!cur_page_statisify_filter(filter)) {
+ } else if (!cur_page_may_satisfy_filter(filter)) {
if (RET_FAIL(skip_cur_page())) {
}
} else if (should_skip_page_by_time(min_time_hint)) {
if (RET_FAIL(skip_cur_page())) {
}
- } else if (should_skip_page_by_offset(row_offset)) {
+ } else if (cur_page_fully_satisfies_filter(filter) &&
+ should_skip_page_by_offset(row_offset)) {
if (RET_FAIL(skip_cur_page())) {
}
} else {
@@ -924,4 +935,4 @@ int ChunkReader::get_next_page(TsBlock* ret_tsblock,
Filter* oneshoot_filter,
return ret;
}
-} // end namespace storage
\ No newline at end of file
+} // end namespace storage
diff --git a/cpp/src/reader/chunk_reader.h b/cpp/src/reader/chunk_reader.h
index a1196c330..b92be8169 100644
--- a/cpp/src/reader/chunk_reader.h
+++ b/cpp/src/reader/chunk_reader.h
@@ -87,7 +87,8 @@ class ChunkReader : public IChunkReader {
common::CompressionType compression_type);
int get_cur_page_header();
int read_from_file_and_rewrap(int want_size = 0);
- bool cur_page_statisify_filter(Filter* filter);
+ bool cur_page_may_satisfy_filter(Filter* filter);
+ bool cur_page_fully_satisfies_filter(Filter* filter);
int skip_cur_page();
int decode_cur_page_data(common::TsBlock*& ret_tsblock, Filter* filter,
common::PageArena& pa);
diff --git a/cpp/src/reader/tsfile_series_scan_iterator.cc
b/cpp/src/reader/tsfile_series_scan_iterator.cc
index 6e318b38e..79ac8d308 100644
--- a/cpp/src/reader/tsfile_series_scan_iterator.cc
+++ b/cpp/src/reader/tsfile_series_scan_iterator.cc
@@ -30,6 +30,21 @@ using namespace common;
namespace storage {
+namespace {
+bool chunk_may_satisfy_filter(ChunkMeta* chunk_meta, Filter* filter) {
+ return filter == nullptr || chunk_meta == nullptr ||
+ chunk_meta->statistic_ == nullptr ||
+ filter->satisfy(chunk_meta->statistic_);
+}
+
+bool chunk_fully_satisfies_filter(ChunkMeta* chunk_meta, Filter* filter) {
+ return filter == nullptr ||
+ (chunk_meta != nullptr && chunk_meta->statistic_ != nullptr &&
+ filter->contain_start_end_time(chunk_meta->statistic_->start_time_,
+ chunk_meta->statistic_->end_time_));
+}
+} // namespace
+
void TsFileSeriesScanIterator::destroy() {
// MultiAlignedTimeseriesIndex is placement-new'd inside
// timeseries_index_pa_ (see TsFileIOReader::alloc_multi_ssi). The arena's
@@ -122,6 +137,31 @@ bool
TsFileSeriesScanIterator::should_skip_aligned_chunk_by_offset(
return false;
}
+bool TsFileSeriesScanIterator::should_skip_multi_aligned_chunk_by_offset(
+ ChunkMeta* time_cm, const std::vector<ChunkMeta*>& value_cms) {
+ if (row_offset_ <= 0) {
+ return false;
+ }
+ if (time_cm == nullptr || time_cm->statistic_ == nullptr) {
+ return false;
+ }
+ int32_t time_count = time_cm->statistic_->count_;
+ if (time_count <= 0) {
+ return false;
+ }
+ for (const auto* value_cm : value_cms) {
+ if (value_cm == nullptr || value_cm->statistic_ == nullptr ||
+ value_cm->statistic_->count_ != time_count) {
+ return false;
+ }
+ }
+ if (row_offset_ >= time_count) {
+ row_offset_ -= time_count;
+ return true;
+ }
+ return false;
+}
+
int TsFileSeriesScanIterator::get_next(TsBlock*& ret_tsblock, bool alloc,
Filter* oneshoot_filter,
int64_t min_time_hint) {
@@ -151,13 +191,17 @@ int TsFileSeriesScanIterator::get_next(TsBlock*&
ret_tsblock, bool alloc,
}
advance_to_next_chunk();
// Skip chunk by time filter using time chunk statistics.
- if (filter != nullptr && time_cm->statistic_ != nullptr &&
- !filter->satisfy(time_cm->statistic_)) {
+ if (!chunk_may_satisfy_filter(time_cm, filter)) {
continue;
}
if (should_skip_chunk_by_time(time_cm, min_time_hint)) {
continue;
}
+ if (chunk_fully_satisfies_filter(time_cm, filter) &&
+ should_skip_multi_aligned_chunk_by_offset(time_cm,
+ value_cms)) {
+ continue;
+ }
chunk_reader_->reset();
auto* acr =
static_cast<AlignedChunkReader*>(chunk_reader_);
if (RET_FAIL(acr->load_by_aligned_meta_multi(time_cm,
@@ -167,8 +211,7 @@ int TsFileSeriesScanIterator::get_next(TsBlock*&
ret_tsblock, bool alloc,
} else if (!is_aligned_) {
ChunkMeta* cm = get_current_chunk_meta();
advance_to_next_chunk();
- if (filter != nullptr && cm->statistic_ != nullptr &&
- !filter->satisfy(cm->statistic_)) {
+ if (!chunk_may_satisfy_filter(cm, filter)) {
continue;
}
// Skip by min_time_hint (merge cursor).
@@ -176,7 +219,8 @@ int TsFileSeriesScanIterator::get_next(TsBlock*&
ret_tsblock, bool alloc,
continue;
}
// Single-path: skip entire chunk by offset using count.
- if (should_skip_chunk_by_offset(cm)) {
+ if (chunk_fully_satisfies_filter(cm, filter) &&
+ should_skip_chunk_by_offset(cm)) {
continue;
}
chunk_reader_->reset();
@@ -190,14 +234,14 @@ int TsFileSeriesScanIterator::get_next(TsBlock*&
ret_tsblock, bool alloc,
// Use time chunk statistics for time-based filtering.
ChunkMeta* filter_cm =
(time_cm->statistic_ != nullptr) ? time_cm : value_cm;
- if (filter != nullptr && filter_cm->statistic_ != nullptr
&&
- !filter->satisfy(filter_cm->statistic_)) {
+ if (!chunk_may_satisfy_filter(filter_cm, filter)) {
continue;
}
if (should_skip_chunk_by_time(filter_cm, min_time_hint)) {
continue;
}
- if (should_skip_aligned_chunk_by_offset(time_cm,
+ if (chunk_fully_satisfies_filter(time_cm, filter) &&
+ should_skip_aligned_chunk_by_offset(time_cm,
value_cm)) {
continue;
}
diff --git a/cpp/src/reader/tsfile_series_scan_iterator.h
b/cpp/src/reader/tsfile_series_scan_iterator.h
index 68f1a1f32..cb3832787 100644
--- a/cpp/src/reader/tsfile_series_scan_iterator.h
+++ b/cpp/src/reader/tsfile_series_scan_iterator.h
@@ -81,6 +81,12 @@ class TsFileSeriesScanIterator {
* for single-path. */
int get_row_offset() const { return row_offset_; }
int get_row_limit() const { return row_limit_; }
+ void consume_row_offset(int count) {
+ if (count <= 0 || row_offset_ <= 0) {
+ return;
+ }
+ row_offset_ = count >= row_offset_ ? 0 : row_offset_ - count;
+ }
/*
* If oneshoot filter specified, use it instead of this->time_filter_.
@@ -180,6 +186,8 @@ class TsFileSeriesScanIterator {
bool should_skip_chunk_by_offset(ChunkMeta* cm);
bool should_skip_aligned_chunk_by_offset(ChunkMeta* time_cm,
ChunkMeta* value_cm);
+ bool should_skip_multi_aligned_chunk_by_offset(
+ ChunkMeta* time_cm, const std::vector<ChunkMeta*>& value_cms);
common::TsBlock* alloc_tsblock();
common::TsBlock* alloc_tsblock_multi();
diff --git a/cpp/test/reader/tsfile_reader_test.cc
b/cpp/test/reader/tsfile_reader_test.cc
index 2e74e870f..f7df9c8c9 100644
--- a/cpp/test/reader/tsfile_reader_test.cc
+++ b/cpp/test/reader/tsfile_reader_test.cc
@@ -42,6 +42,23 @@
using namespace storage;
using namespace common;
+TEST(TsFileSeriesScanIteratorTest, ConsumeRowOffsetSaturates) {
+ storage::TsFileSeriesScanIterator ssi;
+ ssi.set_row_range(/*offset=*/10, /*limit=*/-1);
+
+ ssi.consume_row_offset(-1);
+ EXPECT_EQ(ssi.get_row_offset(), 10);
+ ssi.consume_row_offset(std::numeric_limits<int>::min());
+ EXPECT_EQ(ssi.get_row_offset(), 10);
+
+ ssi.consume_row_offset(4);
+ EXPECT_EQ(ssi.get_row_offset(), 6);
+ ssi.consume_row_offset(6);
+ EXPECT_EQ(ssi.get_row_offset(), 0);
+ ssi.consume_row_offset(1);
+ EXPECT_EQ(ssi.get_row_offset(), 0);
+}
+
class TsFileReaderTest : public ::testing::Test {
protected:
void SetUp() override {
@@ -463,12 +480,10 @@ TEST_F(TsFileReaderTest,
reader.close();
}
-// Multi-value aligned chunk reader doesn't honour row_offset / row_limit /
-// min_time_hint pushdown — silently dropping those args would hand the caller
-// full-chunk data when it asked for a sub-range. The guard at the top of
-// AlignedChunkReader::get_next_page must turn the unsupported combination
-// into an explicit E_NOT_SUPPORT.
-TEST_F(TsFileReaderTest, MultiValueAlignedRowOffsetReturnsNotSupport) {
+// The multi-value page plan consumes row_offset before value decoding. When
+// the offset lands inside a page, the returned TsBlock starts at the first row
+// after the offset instead of exposing a residual to the row reader.
+TEST_F(TsFileReaderTest, MultiValueAlignedRowOffsetTrimsPartialPage) {
const std::string device = "root.dev_multi_offset";
std::vector<MeasurementSchema> schema_vec;
schema_vec.emplace_back("v0", INT64, PLAIN, UNCOMPRESSED);
@@ -505,12 +520,21 @@ TEST_F(TsFileReaderTest,
MultiValueAlignedRowOffsetReturnsNotSupport) {
E_OK);
ASSERT_NE(ssi, nullptr);
- // row_offset > 0 hits the multi-value guard at the top of
- // AlignedChunkReader::get_next_page; the SSI propagates the error code.
ssi->set_row_range(/*offset=*/5, /*limit=*/-1);
common::TsBlock* block = nullptr;
- EXPECT_EQ(ssi->get_next(block, /*alloc_tsblock=*/true),
- common::E_NOT_SUPPORT);
+ EXPECT_EQ(ssi->get_next(block, /*alloc_tsblock=*/true), common::E_OK);
+ ASSERT_NE(block, nullptr);
+ EXPECT_EQ(block->get_row_count(), static_cast<uint32_t>(N - 5));
+ EXPECT_EQ(ssi->get_row_offset(), 0);
+ {
+ common::ColIterator time_iter(0, block);
+ common::ColIterator v0_iter(1, block);
+ common::ColIterator v1_iter(2, block);
+ uint32_t len = 0;
+ EXPECT_EQ(*reinterpret_cast<int64_t*>(time_iter.read(&len)), 1005);
+ EXPECT_EQ(*reinterpret_cast<int64_t*>(v0_iter.read(&len)), 5);
+ EXPECT_EQ(*reinterpret_cast<int64_t*>(v1_iter.read(&len)), 10);
+ }
if (block != nullptr) {
ssi->revert_tsblock();
@@ -521,6 +545,423 @@ TEST_F(TsFileReaderTest,
MultiValueAlignedRowOffsetReturnsNotSupport) {
// it, then ~TsFileMeta would call self_deleter on freed memory.
}
+TEST_F(TsFileReaderTest, MultiValueAlignedRowOffsetSkipsWholePage) {
+ struct PagePointGuard {
+ explicit PagePointGuard(uint32_t page_points)
+ : saved_(common::g_config_value_.page_writer_max_point_num_) {
+ common::g_config_value_.page_writer_max_point_num_ = page_points;
+ }
+ ~PagePointGuard() {
+ common::g_config_value_.page_writer_max_point_num_ = saved_;
+ }
+ uint32_t saved_;
+ } page_point_guard(16);
+
+ const std::string device = "root.dev_multi_page_offset";
+ std::vector<MeasurementSchema> schema_vec;
+ schema_vec.emplace_back("v0", INT64, PLAIN, UNCOMPRESSED);
+ schema_vec.emplace_back("v1", INT64, PLAIN, UNCOMPRESSED);
+ {
+ std::vector<MeasurementSchema*> reg;
+ for (auto& s : schema_vec) reg.push_back(new MeasurementSchema(s));
+ ASSERT_EQ(tsfile_writer_->register_aligned_timeseries(device, reg),
+ E_OK);
+ }
+
+ const int rows_per_page = 16;
+ const int N = rows_per_page * 4;
+ Tablet tablet(device,
+ std::make_shared<std::vector<MeasurementSchema>>(schema_vec),
+ N);
+ for (int i = 0; i < N; ++i) {
+ ASSERT_EQ(tablet.add_timestamp(i, static_cast<int64_t>(i)), E_OK);
+ ASSERT_EQ(tablet.add_value(i, 0u, static_cast<int64_t>(i)), E_OK);
+ ASSERT_EQ(tablet.add_value(i, 1u, static_cast<int64_t>(i * 2)), E_OK);
+ }
+ ASSERT_EQ(tsfile_writer_->write_tablet_aligned(tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ ASSERT_EQ(tsfile_writer_->close(), E_OK);
+
+ storage::TsFileIOReader io_reader;
+ ASSERT_EQ(io_reader.init(file_name_), E_OK);
+
+ auto device_id = std::make_shared<StringArrayDeviceID>(device);
+ std::vector<std::string> measurements = {"v0", "v1"};
+ storage::TsFileSeriesScanIterator* ssi = nullptr;
+ common::PageArena pa;
+ pa.init(512, common::MOD_TSFILE_READER);
+ ASSERT_EQ(io_reader.alloc_multi_ssi(device_id, measurements, ssi, pa,
+ /*time_filter=*/nullptr),
+ E_OK);
+ ASSERT_NE(ssi, nullptr);
+
+ // The first chunk is preloaded before set_row_range(). The page plan skips
+ // page 0 and trims the first four rows from page 1 before value decoding.
+ ssi->set_row_range(/*offset=*/20, /*limit=*/-1);
+ common::TsBlock* block = nullptr;
+ ASSERT_EQ(ssi->get_next(block, /*alloc_tsblock=*/true), common::E_OK);
+ ASSERT_NE(block, nullptr);
+ ASSERT_EQ(block->get_row_count(), static_cast<uint32_t>(N - 20));
+ EXPECT_EQ(ssi->get_row_offset(), 0);
+
+ {
+ common::ColIterator time_iter(0, block);
+ common::ColIterator v0_iter(1, block);
+ common::ColIterator v1_iter(2, block);
+ uint32_t len = 0;
+ EXPECT_EQ(*reinterpret_cast<int64_t*>(time_iter.read(&len)), 20);
+ EXPECT_EQ(*reinterpret_cast<int64_t*>(v0_iter.read(&len)), 20);
+ EXPECT_EQ(*reinterpret_cast<int64_t*>(v1_iter.read(&len)), 40);
+ }
+
+ ssi->revert_tsblock();
+ io_reader.revert_ssi(ssi);
+}
+
+TEST_F(TsFileReaderTest, MultiValueAlignedRowOffsetSkipsWholeChunk) {
+ const std::string device = "root.dev_multi_chunk_offset";
+ std::vector<MeasurementSchema> schema_vec;
+ schema_vec.emplace_back("v0", INT64, PLAIN, UNCOMPRESSED);
+ schema_vec.emplace_back("v1", INT64, PLAIN, UNCOMPRESSED);
+ {
+ std::vector<MeasurementSchema*> reg;
+ for (auto& s : schema_vec) reg.push_back(new MeasurementSchema(s));
+ ASSERT_EQ(tsfile_writer_->register_aligned_timeseries(device, reg),
+ E_OK);
+ }
+
+ const int rows_per_chunk = 64;
+ const int chunk_count = 4;
+ for (int chunk = 0; chunk < chunk_count; ++chunk) {
+ Tablet tablet(
+ device,
+ std::make_shared<std::vector<MeasurementSchema>>(schema_vec),
+ rows_per_chunk);
+ const int base = chunk * rows_per_chunk;
+ for (int i = 0; i < rows_per_chunk; ++i) {
+ const int row = base + i;
+ ASSERT_EQ(tablet.add_timestamp(i, static_cast<int64_t>(row)),
E_OK);
+ ASSERT_EQ(tablet.add_value(i, 0u, static_cast<int64_t>(row)),
E_OK);
+ ASSERT_EQ(tablet.add_value(i, 1u, static_cast<int64_t>(row * 2)),
+ E_OK);
+ }
+ ASSERT_EQ(tsfile_writer_->write_tablet_aligned(tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ }
+ ASSERT_EQ(tsfile_writer_->close(), E_OK);
+
+ storage::TsFileIOReader io_reader;
+ ASSERT_EQ(io_reader.init(file_name_), E_OK);
+
+ auto device_id = std::make_shared<StringArrayDeviceID>(device);
+ std::vector<std::string> measurements = {"v0", "v1"};
+ storage::TsFileSeriesScanIterator* ssi = nullptr;
+ common::PageArena pa;
+ pa.init(512, common::MOD_TSFILE_READER);
+ ASSERT_EQ(io_reader.alloc_multi_ssi(device_id, measurements, ssi, pa,
+ /*time_filter=*/nullptr),
+ E_OK);
+ ASSERT_NE(ssi, nullptr);
+
+ // alloc_multi_ssi() preloads chunk 0 before row range is set. Read it
+ // normally first, then set an offset that skips the next whole chunk by
+ // chunk metadata count and trims the first 32 rows from chunk 2's plan.
+ common::TsBlock* block = nullptr;
+ ASSERT_EQ(ssi->get_next(block, /*alloc_tsblock=*/true), common::E_OK);
+ ASSERT_NE(block, nullptr);
+ ASSERT_EQ(block->get_row_count(), static_cast<uint32_t>(rows_per_chunk));
+ {
+ common::ColIterator time_iter(0, block);
+ common::ColIterator v0_iter(1, block);
+ common::ColIterator v1_iter(2, block);
+ uint32_t len = 0;
+ EXPECT_EQ(*reinterpret_cast<int64_t*>(time_iter.read(&len)), 0);
+ EXPECT_EQ(*reinterpret_cast<int64_t*>(v0_iter.read(&len)), 0);
+ EXPECT_EQ(*reinterpret_cast<int64_t*>(v1_iter.read(&len)), 0);
+ }
+ ssi->revert_tsblock();
+
+ ssi->set_row_range(/*offset=*/96, /*limit=*/-1);
+ block = nullptr;
+ ASSERT_EQ(ssi->get_next(block, /*alloc_tsblock=*/true), common::E_OK);
+ ASSERT_NE(block, nullptr);
+ ASSERT_EQ(block->get_row_count(),
+ static_cast<uint32_t>(rows_per_chunk / 2));
+ EXPECT_EQ(ssi->get_row_offset(), 0);
+
+ {
+ common::ColIterator time_iter(0, block);
+ common::ColIterator v0_iter(1, block);
+ common::ColIterator v1_iter(2, block);
+ uint32_t len = 0;
+ EXPECT_EQ(*reinterpret_cast<int64_t*>(time_iter.read(&len)), 160);
+ EXPECT_EQ(*reinterpret_cast<int64_t*>(v0_iter.read(&len)), 160);
+ EXPECT_EQ(*reinterpret_cast<int64_t*>(v1_iter.read(&len)), 320);
+ }
+
+ ssi->revert_tsblock();
+ io_reader.revert_ssi(ssi);
+}
+
+// Offset is defined over the rows left after filtering. A partially matching
+// chunk therefore cannot consume its full time-row count from the offset.
+TEST_F(TsFileReaderTest, MultiValueAlignedRowOffsetCountsOnlyFilteredRows) {
+ const std::string device = "root.dev_multi_filtered_offset";
+ std::vector<MeasurementSchema> schema_vec;
+ schema_vec.emplace_back("v0", INT64, PLAIN, UNCOMPRESSED);
+ schema_vec.emplace_back("v1", INT64, PLAIN, UNCOMPRESSED);
+ {
+ std::vector<MeasurementSchema*> reg;
+ for (auto& s : schema_vec) reg.push_back(new MeasurementSchema(s));
+ ASSERT_EQ(tsfile_writer_->register_aligned_timeseries(device, reg),
+ E_OK);
+ }
+
+ const int rows_per_chunk = 64;
+ const int chunk_count = 4;
+ for (int chunk = 0; chunk < chunk_count; ++chunk) {
+ Tablet tablet(
+ device,
+ std::make_shared<std::vector<MeasurementSchema>>(schema_vec),
+ rows_per_chunk);
+ const int base = chunk * rows_per_chunk;
+ for (int i = 0; i < rows_per_chunk; ++i) {
+ const int row = base + i;
+ ASSERT_EQ(tablet.add_timestamp(i, static_cast<int64_t>(row)),
E_OK);
+ ASSERT_EQ(tablet.add_value(i, 0u, static_cast<int64_t>(row)),
E_OK);
+ ASSERT_EQ(tablet.add_value(i, 1u, static_cast<int64_t>(row * 2)),
+ E_OK);
+ }
+ ASSERT_EQ(tsfile_writer_->write_tablet_aligned(tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ }
+ ASSERT_EQ(tsfile_writer_->close(), E_OK);
+
+ storage::TsFileIOReader io_reader;
+ ASSERT_EQ(io_reader.init(file_name_), E_OK);
+
+ auto device_id = std::make_shared<StringArrayDeviceID>(device);
+ std::vector<std::string> measurements = {"v0", "v1"};
+ storage::TsFileSeriesScanIterator* ssi = nullptr;
+ common::PageArena pa;
+ pa.init(512, common::MOD_TSFILE_READER);
+
+ // Keep all 64 rows from chunk 0, only 10 rows from chunk 1, and all 128
+ // rows from chunks 2 and 3. The filtered stream has 202 rows, so offset
+ // 200 must leave only timestamps 254 and 255.
+ std::vector<int64_t> selected_times;
+ for (int64_t t = 0; t < 64; ++t) selected_times.push_back(t);
+ for (int64_t t = 64; t < 74; ++t) selected_times.push_back(t);
+ for (int64_t t = 128; t < 256; ++t) selected_times.push_back(t);
+ storage::TimeIn time_filter(selected_times, /*not_in=*/false);
+
+ ASSERT_EQ(io_reader.alloc_multi_ssi(device_id, measurements, ssi, pa,
+ &time_filter),
+ E_OK);
+ ASSERT_NE(ssi, nullptr);
+ ssi->set_row_range(/*offset=*/200, /*limit=*/-1);
+
+ std::vector<int64_t> actual_times;
+ while (true) {
+ common::TsBlock* block = nullptr;
+ int ret = ssi->get_next(block, /*alloc_tsblock=*/true, &time_filter);
+ if (ret == common::E_NO_MORE_DATA) break;
+ ASSERT_EQ(ret, common::E_OK);
+ ASSERT_NE(block, nullptr);
+
+ const uint32_t rows = block->get_row_count();
+ const int remaining_offset = ssi->get_row_offset();
+ const uint32_t rows_to_skip = std::min(
+ rows, static_cast<uint32_t>(std::max(remaining_offset, 0)));
+ {
+ common::ColIterator time_iter(0, block);
+ for (uint32_t row = 0; row < rows; ++row) {
+ uint32_t len = 0;
+ int64_t time =
+ *reinterpret_cast<int64_t*>(time_iter.read(&len));
+ if (row >= rows_to_skip) actual_times.push_back(time);
+ time_iter.next();
+ }
+ }
+ ssi->set_row_range(remaining_offset - static_cast<int>(rows_to_skip),
+ /*limit=*/-1);
+ ssi->revert_tsblock();
+ }
+
+ ASSERT_EQ(actual_times.size(), 2u);
+ EXPECT_EQ(actual_times[0], 254);
+ EXPECT_EQ(actual_times[1], 255);
+
+ io_reader.revert_ssi(ssi);
+}
+
+// A boundary page may contain holes between matching timestamps. Offset must
+// consume only matching rows and keep every value column aligned with the
+// first retained match.
+TEST_F(TsFileReaderTest, MultiValueAlignedPagePlanOffsetSkipsBoundaryMatches) {
+ const std::string device = "root.dev_multi_boundary_offset";
+ std::vector<MeasurementSchema> schema_vec;
+ schema_vec.emplace_back("v0", INT64, PLAIN, UNCOMPRESSED);
+ schema_vec.emplace_back("v1", INT64, PLAIN, UNCOMPRESSED);
+ {
+ std::vector<MeasurementSchema*> reg;
+ for (auto& s : schema_vec) reg.push_back(new MeasurementSchema(s));
+ ASSERT_EQ(tsfile_writer_->register_aligned_timeseries(device, reg),
+ E_OK);
+ }
+
+ constexpr int kRowCount = 16;
+ Tablet tablet(device,
+ std::make_shared<std::vector<MeasurementSchema>>(schema_vec),
+ kRowCount);
+ for (int i = 0; i < kRowCount; ++i) {
+ ASSERT_EQ(tablet.add_timestamp(i, i), E_OK);
+ ASSERT_EQ(tablet.add_value(i, 0u, static_cast<int64_t>(i)), E_OK);
+ ASSERT_EQ(tablet.add_value(i, 1u, static_cast<int64_t>(i * 10)), E_OK);
+ }
+ ASSERT_EQ(tsfile_writer_->write_tablet_aligned(tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ ASSERT_EQ(tsfile_writer_->close(), E_OK);
+
+ storage::TsFileIOReader io_reader;
+ ASSERT_EQ(io_reader.init(file_name_), E_OK);
+
+ auto device_id = std::make_shared<StringArrayDeviceID>(device);
+ std::vector<std::string> measurements = {"v0", "v1"};
+ storage::TsFileSeriesScanIterator* ssi = nullptr;
+ common::PageArena pa;
+ pa.init(512, common::MOD_TSFILE_READER);
+ const std::vector<int64_t> selected_times = {1, 3, 5, 7, 9};
+ storage::TimeIn time_filter(selected_times, /*not_in=*/false);
+ ASSERT_EQ(io_reader.alloc_multi_ssi(device_id, measurements, ssi, pa,
+ &time_filter),
+ E_OK);
+ ASSERT_NE(ssi, nullptr);
+
+ ssi->set_row_range(/*offset=*/2, /*limit=*/-1);
+ common::TsBlock* block = nullptr;
+ ASSERT_EQ(ssi->get_next(block, /*alloc_tsblock=*/true, &time_filter),
E_OK);
+ ASSERT_NE(block, nullptr);
+ EXPECT_EQ(ssi->get_row_offset(), 0);
+ ASSERT_EQ(block->get_row_count(), 3u);
+
+ const std::vector<int64_t> expected_times = {5, 7, 9};
+ {
+ common::ColIterator time_iter(0, block);
+ common::ColIterator v0_iter(1, block);
+ common::ColIterator v1_iter(2, block);
+ for (int64_t expected : expected_times) {
+ uint32_t len = 0;
+ EXPECT_EQ(*reinterpret_cast<int64_t*>(time_iter.read(&len)),
+ expected);
+ EXPECT_EQ(*reinterpret_cast<int64_t*>(v0_iter.read(&len)),
+ expected);
+ EXPECT_EQ(*reinterpret_cast<int64_t*>(v1_iter.read(&len)),
+ expected * 10);
+ time_iter.next();
+ v0_iter.next();
+ v1_iter.next();
+ }
+ }
+
+ ssi->revert_tsblock();
+ io_reader.revert_ssi(ssi);
+}
+
+// The single-value SSI paths must apply offset to the filtered stream too.
+// Cover both a non-aligned series (ChunkReader) and a single-value aligned
+// series (AlignedChunkReader), including their chunk- and page-level skips.
+TEST_F(TsFileReaderTest, SingleValueRowOffsetCountsOnlyFilteredRows) {
+ const std::string non_aligned_device = "root.dev_filtered_offset";
+ const std::string aligned_device = "root.dev_aligned_filtered_offset";
+ MeasurementSchema schema("v0", INT64, PLAIN, UNCOMPRESSED);
+ ASSERT_EQ(tsfile_writer_->register_timeseries(non_aligned_device, schema),
+ E_OK);
+ ASSERT_EQ(
+ tsfile_writer_->register_aligned_timeseries(aligned_device, schema),
+ E_OK);
+
+ const int rows_per_chunk = 64;
+ const int chunk_count = 4;
+ auto schemas = std::make_shared<std::vector<MeasurementSchema>>(1, schema);
+ for (int chunk = 0; chunk < chunk_count; ++chunk) {
+ const int base = chunk * rows_per_chunk;
+ Tablet non_aligned_tablet(non_aligned_device, schemas, rows_per_chunk);
+ Tablet aligned_tablet(aligned_device, schemas, rows_per_chunk);
+ for (int i = 0; i < rows_per_chunk; ++i) {
+ const int row = base + i;
+ ASSERT_EQ(non_aligned_tablet.add_timestamp(i, row), E_OK);
+ ASSERT_EQ(
+ non_aligned_tablet.add_value(i, 0u, static_cast<int64_t>(row)),
+ E_OK);
+ ASSERT_EQ(aligned_tablet.add_timestamp(i, row), E_OK);
+ ASSERT_EQ(
+ aligned_tablet.add_value(i, 0u, static_cast<int64_t>(row)),
+ E_OK);
+ }
+ ASSERT_EQ(tsfile_writer_->write_tablet(non_aligned_tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->write_tablet_aligned(aligned_tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ }
+ ASSERT_EQ(tsfile_writer_->close(), E_OK);
+
+ std::vector<int64_t> selected_times;
+ for (int64_t t = 0; t < 64; ++t) selected_times.push_back(t);
+ for (int64_t t = 64; t < 74; ++t) selected_times.push_back(t);
+ for (int64_t t = 128; t < 256; ++t) selected_times.push_back(t);
+ storage::TimeIn time_filter(selected_times, /*not_in=*/false);
+
+ storage::TsFileIOReader io_reader;
+ ASSERT_EQ(io_reader.init(file_name_), E_OK);
+ for (const std::string& device : {non_aligned_device, aligned_device}) {
+ SCOPED_TRACE(device);
+ auto device_id = std::make_shared<StringArrayDeviceID>(device);
+ storage::TsFileSeriesScanIterator* ssi = nullptr;
+ common::PageArena pa;
+ pa.init(512, common::MOD_TSFILE_READER);
+ ASSERT_EQ(io_reader.alloc_ssi(device_id, "v0", ssi, pa, &time_filter),
+ E_OK);
+ ASSERT_NE(ssi, nullptr);
+ ssi->set_row_range(/*offset=*/200, /*limit=*/-1);
+
+ std::vector<int64_t> actual_times;
+ while (true) {
+ common::TsBlock* block = nullptr;
+ int ret =
+ ssi->get_next(block, /*alloc_tsblock=*/true, &time_filter);
+ if (ret == common::E_NO_MORE_DATA) break;
+ ASSERT_EQ(ret, common::E_OK);
+ ASSERT_NE(block, nullptr);
+
+ const uint32_t rows = block->get_row_count();
+ const int remaining_offset = ssi->get_row_offset();
+ const uint32_t rows_to_skip = std::min(
+ rows, static_cast<uint32_t>(std::max(remaining_offset, 0)));
+ {
+ common::ColIterator time_iter(0, block);
+ for (uint32_t row = 0; row < rows; ++row) {
+ uint32_t len = 0;
+ int64_t time =
+ *reinterpret_cast<int64_t*>(time_iter.read(&len));
+ if (row >= rows_to_skip) actual_times.push_back(time);
+ time_iter.next();
+ }
+ }
+ ssi->set_row_range(
+ remaining_offset - static_cast<int>(rows_to_skip),
+ /*limit=*/-1);
+ ssi->revert_tsblock();
+ }
+
+ EXPECT_EQ(actual_times.size(), 2u);
+ if (actual_times.size() == 2u) {
+ EXPECT_EQ(actual_times[0], 254);
+ EXPECT_EQ(actual_times[1], 255);
+ }
+ io_reader.revert_ssi(ssi);
+ }
+}
+
namespace storage {
// Subclass that lets the test (a) inject an error from the next-tsblock load
// and (b) wire a manually constructed TsBlock into the inherited iterator