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 bf36d8046 fix(cpp): never seal empty chunks and fix dangling ref in
parallel tablet write (#909)
bf36d8046 is described below
commit bf36d80460c6ef73649b2ea2e57e30ad2b5c42fc
Author: kkzi <[email protected]>
AuthorDate: Wed Aug 26 14:59:39 2026 +0800
fix(cpp): never seal empty chunks and fix dangling ref in parallel tablet
write (#909)
* fix(cpp): never seal empty chunks and fix dangling ref in parallel tablet
write
Two fixes in TsFileWriter:
1. flush_chunk_group / flush_chunk_group_encoded: skip registered-but-empty
measurement columns. A measurement that received no data in a window used
to be sealed as an EMPTY chunk (count=0, dataSize=0). Java readers
(TsFileSequenceReader self-check) treat such a file as crashed and refuse
to load it. Mirror the aligned branch's existing hasData() check so empty
columns never produce a chunk.
2. write_table (aligned parallel path): the submitted tasks run
asynchronously
on the thread pool, but the lambdas captured the loop variables (ctx, vt)
by reference. Once the loop advances, every queued task reads the same /
already-destroyed loop variable. Capture the per-iteration addresses by
value instead.
* style(cpp): fix spotless clang-format violations in parallel tablet write
* test(cpp): add regression tests for empty-chunk seal and parallel tablet
write
Covers the two fixes in this PR (issue #908):
1. Never seal registered-but-empty measurements as count=0 chunks
(non-aligned flush path). Seven tests drive write_tablet,
write_record, multi-window flush, empty-window-then-write,
sibling devices, and mixed aligned/non-aligned devices, asserting
via TsFileReader timeseries metadata that an unwritten measurement
is absent from the file while surviving columns carry real
statistics.
2. Parallel aligned tablet write capture-by-value. Five tests drive
the thread-pool path in write_table() through TsFileTableWriter
with multiple devices x columns x rows crossing page boundaries,
and verify every cell round-trips (row completeness, per-cell
values, tag correctness).
Verified: with both fixes temporarily reverted, the empty-chunk
tests fail (empty column sealed, meta.size()==3); with the fixes in
place all 12 tests pass (10 consecutive runs) and the full TsFile_Test
suite (762 tests) passes.
* test(cpp): adversarial additions to empty-chunk and parallel-write
regressions
Fix 1 coverage:
- memory-threshold auto-flush path: check_memory_size_and_may_flush_chunks()
is a second entry into flush_chunk_group that the explicit-flush tests
never drive; drive it via a shrunk chunk_group_size_threshold_.
- partially-null column counter-check: the hasData() guard must not skip
columns with some nulls (null-bitmap fallback path) — only fully empty
ones.
- TEXT column variant: empty TEXT takes the string write path, distinct
from the fixed-width paths used before.
Fix 2 coverage:
- tiny page size (8 points) so pool-thread tasks seal pages repeatedly and
exercise the initial_page_points continuation across batches.
- thread-pool boundary configs: 1-thread pool (serialized workers, worst
case for slot aliasing) and 8-thread pool, via set_thread_count().
Verified: auto-flush and TEXT tests fail with the hasData() guard
reverted; all 17 tests pass with the fixes (and the full TsFile_Test
suite, 767 tests).
* test(cpp): address review — completeness check and comment rewording
Per review feedback on PR #909:
- TinyPageBoundaryRoundTrip now asserts completeness: every row of every
batch must appear in the query results, so a dropped row on the
page-boundary path fails the test instead of going unnoticed.
- Reword the parallel-write comments (source and tests): the old
by-reference captures were lifetime-safe in the current code shape
(device_ctxs outlives all future.get() calls), so the change is a
defensive cleanup, not a dangling-reference fix. Comments now say
exactly that.
* docs(cpp): correct lifetime claim in parallel-write comments
Per Copilot's review on PR #909: the by-value pointer capture is
equivalent in lifetime to the old by-reference captures (each referred
to its own vector element, and the vectors outlive all future.get()
calls), so the previous wording overclaimed that the by-value form
would stay safe if the get() loop were moved out of scope. Task-owned
lifetime would require copying the task inputs. Comments (source and
tests) now state the equivalence instead.
---------
Co-authored-by: gx <[email protected]>
---
cpp/src/writer/tsfile_writer.cc | 34 +-
...tsfile_parallel_tablet_write_regression_test.cc | 483 +++++++++++++++
.../tsfile_writer_empty_chunk_regression_test.cc | 682 +++++++++++++++++++++
3 files changed, 1191 insertions(+), 8 deletions(-)
diff --git a/cpp/src/writer/tsfile_writer.cc b/cpp/src/writer/tsfile_writer.cc
index aa0e555f8..564d1f203 100644
--- a/cpp/src/writer/tsfile_writer.cc
+++ b/cpp/src/writer/tsfile_writer.cc
@@ -1351,17 +1351,26 @@ int TsFileWriter::write_table(Tablet& tablet) {
common::g_thread_pool_ != nullptr) {
std::vector<std::future<int>> futures;
for (auto& ctx : device_ctxs) {
+ // Capture the per-iteration state by pointer value. This
+ // is equivalent in lifetime to the old by-reference
+ // captures (each referred to its own vector element, and
+ // device_ctxs outlives all future.get() calls below) — it
+ // only makes the per-task address explicit. Truly task-
+ // owned lifetime would require copying the task inputs.
+ auto* ctx_ptr = &ctx;
futures.push_back(common::g_thread_pool_->submit(
- [&write_time_segments, &ctx]() {
- return write_time_segments(ctx.tcw, ctx.segments,
- ctx.initial_page_points);
+ [&write_time_segments, ctx_ptr]() {
+ return write_time_segments(
+ ctx_ptr->tcw, ctx_ptr->segments,
+ ctx_ptr->initial_page_points);
}));
for (auto& vt : ctx.value_tasks) {
+ auto* vt_ptr = &vt;
futures.push_back(common::g_thread_pool_->submit(
- [&write_value_segments, &vt, &ctx]() {
+ [&write_value_segments, vt_ptr, ctx_ptr]() {
return write_value_segments(
- vt.vcw, vt.col_idx, ctx.segments,
- ctx.initial_page_points);
+ vt_ptr->vcw, vt_ptr->col_idx,
ctx_ptr->segments,
+ ctx_ptr->initial_page_points);
}));
}
}
@@ -1898,7 +1907,13 @@ int
TsFileWriter::flush_chunk_group_encoded(MeasurementSchemaGroup* chunk_group,
for (MeasurementSchemaMapIter ms_iter = map.begin(); ms_iter != map.end();
ms_iter++) {
MeasurementSchema* m_schema = ms_iter->second;
- if (!chunk_group->is_aligned_ && m_schema->chunk_writer_ != nullptr) {
+ // Skip registered-but-empty columns: a measurement that was never
+ // written in this window would otherwise be sealed as an EMPTY chunk
+ // (count=0, dataSize=0). Java readers (TsFileSequenceReader self-
+ // check) treat such a file as crashed. Mirror the aligned branch's
+ // hasData() check below.
+ if (!chunk_group->is_aligned_ && m_schema->chunk_writer_ != nullptr &&
+ m_schema->chunk_writer_->hasData()) {
ChunkWriter*& chunk_writer = m_schema->chunk_writer_;
FLUSH_CHUNK_ENCODED(
chunk_writer, io_writer_, m_schema->measurement_name_,
@@ -1935,7 +1950,10 @@ int
TsFileWriter::flush_chunk_group(MeasurementSchemaGroup* chunk_group,
for (MeasurementSchemaMapIter ms_iter = map.begin(); ms_iter != map.end();
ms_iter++) {
MeasurementSchema* m_schema = ms_iter->second;
- if (!chunk_group->is_aligned_ && m_schema->chunk_writer_ != nullptr) {
+ // See flush_chunk_group_encoded: never seal a registered-but-empty
+ // column as a count=0 chunk.
+ if (!chunk_group->is_aligned_ && m_schema->chunk_writer_ != nullptr &&
+ m_schema->chunk_writer_->hasData()) {
ChunkWriter*& chunk_writer = m_schema->chunk_writer_;
FLUSH_CHUNK(chunk_writer, io_writer_, m_schema->measurement_name_,
m_schema->data_type_, m_schema->encoding_,
diff --git
a/cpp/test/writer/table_view/tsfile_parallel_tablet_write_regression_test.cc
b/cpp/test/writer/table_view/tsfile_parallel_tablet_write_regression_test.cc
new file mode 100644
index 000000000..034b4baa2
--- /dev/null
+++ b/cpp/test/writer/table_view/tsfile_parallel_tablet_write_regression_test.cc
@@ -0,0 +1,483 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+// Coverage for the parallel aligned tablet write path in
+// TsFileWriter::write_table(), which submits per-device / per-column tasks
+// to the global thread pool. The task lambdas now capture the per-iteration
+// state (ctx / vt) by pointer value rather than by reference. The two forms
+// are equivalent in lifetime — each by-reference capture referred to its own
+// vector element (not a shared loop slot), and the vectors outlive all
+// future.get() calls, so the old captures were lifetime-safe — and the
+// by-value form merely makes the per-task address explicit. Task-owned
+// lifetime would require copying the task inputs. These tests pin the
+// behavior of the parallel
+// path (multiple devices x multiple value columns x enough rows to cross
+// page boundaries) and verify every row survives the round-trip.
+#include <gtest/gtest.h>
+
+#ifdef _WIN32
+#include <process.h>
+#else
+#include <unistd.h>
+#endif
+
+#include <atomic>
+#include <chrono>
+#include <random>
+#include <string>
+#include <vector>
+
+#include "common/schema.h"
+#include "common/tablet.h"
+#include "file/write_file.h"
+#include "reader/tsfile_reader.h"
+#include "writer/tsfile_table_writer.h"
+
+using namespace storage;
+using namespace common;
+
+namespace {
+
+class ParallelTabletWriteRegressionTest : public ::testing::Test {
+ protected:
+ void SetUp() override {
+ libtsfile_init();
+ file_name_ = std::string("tsfile_parallel_write_regression_") +
+ generate_random_string(10) + std::string(".tsfile");
+ remove(file_name_.c_str());
+ int flags = O_WRONLY | O_CREAT | O_TRUNC;
+#ifdef _WIN32
+ flags |= O_BINARY;
+#endif
+ write_file_.create(file_name_, flags, 0666);
+ }
+ void TearDown() override {
+ remove(file_name_.c_str());
+ libtsfile_destroy();
+ }
+
+ std::string file_name_;
+ WriteFile write_file_;
+
+ public:
+ static std::string generate_random_string(int length) {
+ static std::atomic<uint64_t> counter{0};
+ std::mt19937 gen(static_cast<unsigned int>(
+ std::chrono::system_clock::now().time_since_epoch().count()));
+ std::uniform_int_distribution<> dis(0, 61);
+ const std::string chars =
+ "0123456789"
+ "abcdefghijklmnopqrstuvwxyz"
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ std::string random_string;
+ for (int i = 0; i < length; ++i) {
+ random_string += chars[dis(gen)];
+ }
+#ifdef _WIN32
+ const auto process_id = static_cast<uint64_t>(_getpid());
+#else
+ const auto process_id = static_cast<uint64_t>(getpid());
+#endif
+ random_string += "_" + std::to_string(process_id) + "_" +
+ std::to_string(counter.fetch_add(1));
+ return random_string;
+ }
+
+ // 1 TAG column (device id) + `field_col_num` INT64 field columns.
+ static TableSchema* gen_table_schema(int field_col_num) {
+ std::vector<MeasurementSchema*> measurement_schemas;
+ std::vector<ColumnCategory> column_categories;
+ measurement_schemas.emplace_back(
+ new MeasurementSchema("id0", TSDataType::STRING, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED));
+ column_categories.emplace_back(ColumnCategory::TAG);
+ for (int i = 0; i < field_col_num; i++) {
+ measurement_schemas.emplace_back(new MeasurementSchema(
+ "s" + std::to_string(i), TSDataType::INT64, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED));
+ column_categories.emplace_back(ColumnCategory::FIELD);
+ }
+ return new TableSchema("test_table", measurement_schemas,
+ column_categories);
+ }
+
+ // Fill a tablet with rows for `device_num` devices. Device d covers rows
+ // [d * rows_per_device, (d+1) * rows_per_device). Field column c gets
+ // value row * 100 + c so every cell is uniquely identifiable.
+ static void gen_tablet(Tablet& tablet, TableSchema* table_schema,
+ int64_t time_base, int device_num,
+ int rows_per_device, int field_col_num) {
+ tablet.set_table_name("test_table");
+ PageArena pa;
+ pa.init(512, MOD_DEFAULT);
+ for (int d = 0; d < device_num; d++) {
+ std::string device_str = "device_" + std::to_string(d);
+ String literal_str(device_str, pa);
+ for (int l = 0; l < rows_per_device; l++) {
+ int row_index = d * rows_per_device + l;
+ int64_t ts = time_base + row_index;
+ ASSERT_EQ(tablet.add_timestamp(row_index, ts), E_OK);
+ tablet.add_value(row_index, "id0", literal_str);
+ for (int c = 0; c < field_col_num; c++) {
+ tablet.add_value(row_index, "s" + std::to_string(c),
+ static_cast<int64_t>(row_index * 100 +
c));
+ }
+ }
+ }
+ }
+};
+
+// Query the whole table back and verify every cell: row_count rows, tag
+// column matching the owning device, and each field column carrying
+// row_index * 100 + c.
+// Query the table back and verify every cell. The reader returns rows
+// grouped per device in device-id order (not tablet insertion order), so the
+// check is keyed on the timestamp: every row was written with
+// ts = time_base + row_index, field s<c> = row_index * 100 + c, and the tag
+// column = "device_<row_index / rows_per_device>".
+void VerifyTableRoundTrip(const std::string& file_name,
+ TableSchema* table_schema, int device_num,
+ int rows_per_device, int field_col_num,
+ int64_t time_base = 0) {
+ TsFileReader reader;
+ ASSERT_EQ(E_OK, reader.open(file_name));
+ ResultSet* tmp_result_set = nullptr;
+ ASSERT_EQ(E_OK,
+ reader.query("test_table", table_schema->get_measurement_names(),
+ 0, INT64_MAX, tmp_result_set));
+ auto* result_set = (TableResultSet*)tmp_result_set;
+ std::vector<char> seen(static_cast<size_t>(device_num) * rows_per_device,
+ 0);
+ bool has_next = false;
+ int64_t read_rows = 0;
+ while (IS_SUCC(result_set->next(has_next)) && has_next) {
+ const int64_t ts = result_set->get_value<int64_t>("time");
+ ASSERT_GE(ts, time_base);
+ const int64_t row_index = ts - time_base;
+ ASSERT_LT(row_index, static_cast<int64_t>(device_num) *
rows_per_device)
+ << "timestamp out of written range: " << ts;
+ ASSERT_EQ(seen[row_index], 0) << "duplicate row for timestamp " << ts;
+ seen[row_index] = 1;
+
+ const int device_idx = static_cast<int>(row_index / rows_per_device);
+ char literal[32];
+ snprintf(literal, sizeof(literal), "device_%d", device_idx);
+ common::String* tag = result_set->get_value<common::String*>("id0");
+ ASSERT_NE(tag, nullptr);
+ EXPECT_EQ(0, tag->compare(String(literal, strlen(literal))))
+ << "tag mismatch at timestamp " << ts;
+ for (int c = 0; c < field_col_num; c++) {
+ EXPECT_EQ(result_set->get_value<int64_t>("s" + std::to_string(c)),
+ row_index * 100 + c)
+ << "field column s" << c << " corrupted at timestamp " << ts;
+ }
+ read_rows++;
+ }
+ const int64_t total_rows =
+ static_cast<int64_t>(device_num) * rows_per_device;
+ EXPECT_EQ(read_rows, total_rows)
+ << "row loss: parallel aligned write dropped rows";
+ reader.destroy_query_data_set(result_set);
+ reader.close();
+}
+
+} // namespace
+
+// Enough devices and columns that the submission loop enqueues many tasks
+// over distinct ctx / vt entries, plus enough rows to cross the
+// 10000-point page boundary so page sealing also runs per task.
+TEST_F(ParallelTabletWriteRegressionTest, MultiDeviceMultiColumnRoundTrip) {
+ const int device_num = 8;
+ const int rows_per_device = 1500; // > page_writer_max_point_num_
(10000/8)
+ const int field_col_num = 6;
+ auto table_schema = gen_table_schema(field_col_num);
+ auto writer =
+ std::make_shared<TsFileTableWriter>(&write_file_, table_schema);
+ Tablet tablet(table_schema->get_measurement_names(),
+ table_schema->get_data_types(),
+ static_cast<uint32_t>(device_num * rows_per_device));
+ gen_tablet(tablet, table_schema, 0, device_num, rows_per_device,
+ field_col_num);
+ ASSERT_EQ(E_OK, writer->write_table(tablet));
+ ASSERT_EQ(E_OK, writer->flush());
+ ASSERT_EQ(E_OK, writer->close());
+ VerifyTableRoundTrip(file_name_, table_schema, device_num, rows_per_device,
+ field_col_num);
+ delete table_schema;
+}
+
+// Single device, many columns: the per-column ValueTask loop is the inner
+// submission loop; with enough columns the queued tasks span many vt
+// entries.
+TEST_F(ParallelTabletWriteRegressionTest, SingleDeviceManyColumnsRoundTrip) {
+ const int device_num = 1;
+ const int rows_per_device = 12000; // crosses one page boundary
+ const int field_col_num = 16;
+ auto table_schema = gen_table_schema(field_col_num);
+ auto writer =
+ std::make_shared<TsFileTableWriter>(&write_file_, table_schema);
+ Tablet tablet(table_schema->get_measurement_names(),
+ table_schema->get_data_types(),
+ static_cast<uint32_t>(device_num * rows_per_device));
+ gen_tablet(tablet, table_schema, 0, device_num, rows_per_device,
+ field_col_num);
+ ASSERT_EQ(E_OK, writer->write_table(tablet));
+ ASSERT_EQ(E_OK, writer->flush());
+ ASSERT_EQ(E_OK, writer->close());
+ VerifyTableRoundTrip(file_name_, table_schema, device_num, rows_per_device,
+ field_col_num);
+ delete table_schema;
+}
+
+// Many devices but few rows each: maximizes the number of DeviceWriteCtx
+// entries (device_ctxs grows via push_back as the loop progresses), the
+// shape most sensitive to how task state is captured.
+TEST_F(ParallelTabletWriteRegressionTest, ManyDevicesVectorReallocation) {
+ const int device_num = 64;
+ const int rows_per_device = 50;
+ const int field_col_num = 4;
+ auto table_schema = gen_table_schema(field_col_num);
+ auto writer =
+ std::make_shared<TsFileTableWriter>(&write_file_, table_schema);
+ Tablet tablet(table_schema->get_measurement_names(),
+ table_schema->get_data_types(),
+ static_cast<uint32_t>(device_num * rows_per_device));
+ gen_tablet(tablet, table_schema, 0, device_num, rows_per_device,
+ field_col_num);
+ ASSERT_EQ(E_OK, writer->write_table(tablet));
+ ASSERT_EQ(E_OK, writer->flush());
+ ASSERT_EQ(E_OK, writer->close());
+ VerifyTableRoundTrip(file_name_, table_schema, device_num, rows_per_device,
+ field_col_num);
+ delete table_schema;
+}
+
+// Repeated write_table() calls on the same writer: each call builds a fresh
+// device_ctxs vector on the stack. Several sequential batches must all
+// survive the round-trip.
+TEST_F(ParallelTabletWriteRegressionTest, SequentialBatchesRoundTrip) {
+ const int device_num = 4;
+ const int rows_per_device = 300;
+ const int field_col_num = 5;
+ const int batches = 6;
+ auto table_schema = gen_table_schema(field_col_num);
+ auto writer =
+ std::make_shared<TsFileTableWriter>(&write_file_, table_schema);
+ for (int b = 0; b < batches; b++) {
+ Tablet tablet(table_schema->get_measurement_names(),
+ table_schema->get_data_types(),
+ static_cast<uint32_t>(device_num * rows_per_device));
+ gen_tablet(tablet, table_schema, 1000000 + b * 100000, device_num,
+ rows_per_device, field_col_num);
+ ASSERT_EQ(E_OK, writer->write_table(tablet));
+ }
+ ASSERT_EQ(E_OK, writer->flush());
+ ASSERT_EQ(E_OK, writer->close());
+
+ // Each batch wrote the same relative row layout with different
+ // timestamps; total rows = batches * device_num * rows_per_device.
+ TsFileReader reader;
+ ASSERT_EQ(E_OK, reader.open(file_name_));
+ ResultSet* tmp_result_set = nullptr;
+ ASSERT_EQ(E_OK,
+ reader.query("test_table", table_schema->get_measurement_names(),
+ 0, INT64_MAX, tmp_result_set));
+ auto* result_set = (TableResultSet*)tmp_result_set;
+ bool has_next = false;
+ int64_t row_num = 0;
+ while (IS_SUCC(result_set->next(has_next)) && has_next) {
+ row_num++;
+ }
+ EXPECT_EQ(row_num,
+ static_cast<int64_t>(batches) * device_num * rows_per_device)
+ << "row loss across sequential parallel batches";
+ reader.destroy_query_data_set(result_set);
+ reader.close();
+ delete table_schema;
+}
+
+// Interleaved flushes between batches: the parallel path runs while earlier
+// chunk groups are already sealed, and the value writers reused across
+// batches carry non-zero initial_page_points (partial pages). The
+// round-trip check catches any mis-aligned page boundaries.
+TEST_F(ParallelTabletWriteRegressionTest, FlushBetweenBatchesRoundTrip) {
+ const int device_num = 3;
+ const int rows_per_device = 800;
+ const int field_col_num = 3;
+ const int batches = 4;
+ auto table_schema = gen_table_schema(field_col_num);
+ auto writer =
+ std::make_shared<TsFileTableWriter>(&write_file_, table_schema);
+ for (int b = 0; b < batches; b++) {
+ Tablet tablet(table_schema->get_measurement_names(),
+ table_schema->get_data_types(),
+ static_cast<uint32_t>(device_num * rows_per_device));
+ gen_tablet(tablet, table_schema, 500000 + b * 100000, device_num,
+ rows_per_device, field_col_num);
+ ASSERT_EQ(E_OK, writer->write_table(tablet));
+ ASSERT_EQ(E_OK, writer->flush());
+ }
+ ASSERT_EQ(E_OK, writer->close());
+
+ TsFileReader reader;
+ ASSERT_EQ(E_OK, reader.open(file_name_));
+ ResultSet* tmp_result_set = nullptr;
+ ASSERT_EQ(E_OK,
+ reader.query("test_table", table_schema->get_measurement_names(),
+ 0, INT64_MAX, tmp_result_set));
+ auto* result_set = (TableResultSet*)tmp_result_set;
+ bool has_next = false;
+ int64_t row_num = 0;
+ while (IS_SUCC(result_set->next(has_next)) && has_next) {
+ row_num++;
+ }
+ EXPECT_EQ(row_num,
+ static_cast<int64_t>(batches) * device_num * rows_per_device)
+ << "row loss with flush between parallel batches";
+ reader.destroy_query_data_set(result_set);
+ reader.close();
+ delete table_schema;
+}
+
+// ===== Adversarial additions =====
+
+// Small page size (8 points) makes every task seal pages repeatedly on the
+// pool threads and drives the initial_page_points continuation logic hard:
+// rows_per_device=50 crosses 6 page boundaries per column, and the
+// batch-to-batch continuation keeps partial pages live across write_table
+// calls.
+TEST_F(ParallelTabletWriteRegressionTest, TinyPageBoundaryRoundTrip) {
+ const int prev_page_point_num =
+ common::g_config_value_.page_writer_max_point_num_;
+ common::g_config_value_.page_writer_max_point_num_ = 8;
+
+ const int device_num = 4;
+ const int rows_per_device = 50;
+ const int field_col_num = 3;
+ auto table_schema = gen_table_schema(field_col_num);
+ auto writer =
+ std::make_shared<TsFileTableWriter>(&write_file_, table_schema);
+ const int batches = 3;
+ for (int b = 0; b < batches; b++) {
+ Tablet tablet(table_schema->get_measurement_names(),
+ table_schema->get_data_types(),
+ static_cast<uint32_t>(device_num * rows_per_device));
+ gen_tablet(tablet, table_schema, 900000 + b * 100000, device_num,
+ rows_per_device, field_col_num);
+ ASSERT_EQ(E_OK, writer->write_table(tablet));
+ }
+ ASSERT_EQ(E_OK, writer->flush());
+ ASSERT_EQ(E_OK, writer->close());
+
+ // Verify every batch: rows for batch b carry ts in [base, base+total).
+ TsFileReader reader;
+ ASSERT_EQ(E_OK, reader.open(file_name_));
+ for (int b = 0; b < batches; b++) {
+ const int64_t base = 900000 + b * 100000;
+ // Per-batch duplicate tracking: row_index is batch-relative.
+ std::vector<char> seen(static_cast<size_t>(device_num) *
+ rows_per_device);
+ ResultSet* tmp_result_set = nullptr;
+ ASSERT_EQ(E_OK,
+ reader.query(
+ "test_table", table_schema->get_measurement_names(),
base,
+ base + device_num * rows_per_device - 1,
tmp_result_set));
+ auto* result_set = (TableResultSet*)tmp_result_set;
+ bool has_next = false;
+ while (IS_SUCC(result_set->next(has_next)) && has_next) {
+ const int64_t ts = result_set->get_value<int64_t>("time");
+ const int64_t row_index = ts - base;
+ ASSERT_GE(row_index, 0);
+ ASSERT_LT(row_index,
+ static_cast<int64_t>(device_num) * rows_per_device);
+ ASSERT_EQ(seen[row_index], 0)
+ << "duplicate row at timestamp " << ts;
+ seen[row_index] = 1;
+ const int device_idx =
+ static_cast<int>(row_index / rows_per_device);
+ char literal[32];
+ snprintf(literal, sizeof(literal), "device_%d", device_idx);
+ common::String* tag =
result_set->get_value<common::String*>("id0");
+ ASSERT_NE(tag, nullptr);
+ EXPECT_EQ(0, tag->compare(String(literal, strlen(literal))));
+ for (int c = 0; c < field_col_num; c++) {
+ EXPECT_EQ(
+ result_set->get_value<int64_t>("s" + std::to_string(c)),
+ row_index * 100 + c)
+ << "field s" << c << " corrupted at ts " << ts;
+ }
+ }
+ // Completeness: every row of this batch must have been returned —
+ // a dropped row on the page-boundary path must fail the test, not
+ // just go unnoticed (see review on PR #909).
+ for (size_t i = 0; i < seen.size(); i++) {
+ EXPECT_EQ(seen[i], 1)
+ << "row " << i << " of batch " << b
+ << " missing from query results (base ts " << base << ")";
+ }
+ reader.destroy_query_data_set(result_set);
+ }
+ reader.close();
+ delete table_schema;
+
+ common::g_config_value_.page_writer_max_point_num_ = prev_page_point_num;
+}
+
+// Thread-pool boundary configs: a 1-thread pool serializes the tasks on one
+// worker, and a larger pool runs them concurrently. Both must round-trip
+// every row.
+TEST_F(ParallelTabletWriteRegressionTest, ThreadCountBoundariesRoundTrip) {
+ for (int threads : {1, 8}) {
+ ASSERT_EQ(E_OK, set_thread_count(threads));
+ // set_thread_count rebuilds the global pool; TsFileTableWriter is
+ // constructed per iteration so no writer holds state across the
+ // rebuild.
+ WriteFile write_file;
+ std::string file_name =
+ std::string("tsfile_parallel_write_regression_thr") +
+ std::to_string(threads) + "_" + generate_random_string(8) +
+ ".tsfile";
+ remove(file_name.c_str());
+ int flags = O_WRONLY | O_CREAT | O_TRUNC;
+#ifdef _WIN32
+ flags |= O_BINARY;
+#endif
+ write_file.create(file_name, flags, 0666);
+
+ const int device_num = 6;
+ const int rows_per_device = 400;
+ const int field_col_num = 4;
+ auto table_schema = gen_table_schema(field_col_num);
+ auto writer =
+ std::make_shared<TsFileTableWriter>(&write_file, table_schema);
+ Tablet tablet(table_schema->get_measurement_names(),
+ table_schema->get_data_types(),
+ static_cast<uint32_t>(device_num * rows_per_device));
+ gen_tablet(tablet, table_schema, 0, device_num, rows_per_device,
+ field_col_num);
+ ASSERT_EQ(E_OK, writer->write_table(tablet));
+ ASSERT_EQ(E_OK, writer->flush());
+ ASSERT_EQ(E_OK, writer->close());
+ VerifyTableRoundTrip(file_name, table_schema, device_num,
+ rows_per_device, field_col_num);
+ delete table_schema;
+ ASSERT_EQ(0, remove(file_name.c_str()));
+ }
+ // Restore the default pool size for the rest of the suite.
+ ASSERT_EQ(E_OK, set_thread_count(6));
+}
diff --git a/cpp/test/writer/tsfile_writer_empty_chunk_regression_test.cc
b/cpp/test/writer/tsfile_writer_empty_chunk_regression_test.cc
new file mode 100644
index 000000000..47ca4a904
--- /dev/null
+++ b/cpp/test/writer/tsfile_writer_empty_chunk_regression_test.cc
@@ -0,0 +1,682 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+// Regression tests for PR #909 / issue #908:
+// 1. registered-but-empty measurements must never be sealed as count=0
+// chunks (non-aligned flush path in flush_chunk_group /
+// flush_chunk_group_encoded);
+// 2. the parallel aligned tablet write path in write_table() must capture
+// per-iteration state by value so pool threads never read dangling
+// references.
+#include <gtest/gtest.h>
+
+#include "writer/tsfile_writer.h"
+
+#ifdef _WIN32
+#include <process.h>
+#else
+#include <unistd.h>
+#endif
+
+#include <atomic>
+#include <map>
+#include <memory>
+#include <random>
+#include <string>
+#include <vector>
+
+#include "common/path.h"
+#include "common/record.h"
+#include "common/schema.h"
+#include "common/tablet.h"
+#include "common/tsfile_common.h"
+#include "reader/qds_without_timegenerator.h"
+#include "reader/tsfile_reader.h"
+
+using namespace storage;
+using namespace common;
+
+namespace {
+
+class EmptyChunkRegressionTest : public ::testing::Test {
+ protected:
+ void SetUp() override {
+ libtsfile_init();
+ tsfile_writer_ = new TsFileWriter();
+ file_name_ = std::string("tsfile_empty_chunk_regression_") +
+ generate_random_string(10) + std::string(".tsfile");
+ remove(file_name_.c_str());
+ int flags = O_WRONLY | O_CREAT | O_TRUNC;
+#ifdef _WIN32
+ flags |= O_BINARY;
+#endif
+ ASSERT_EQ(tsfile_writer_->open(file_name_, flags, 0666), common::E_OK);
+ }
+ void TearDown() override {
+ delete tsfile_writer_;
+ ASSERT_EQ(0, remove(file_name_.c_str()));
+ libtsfile_destroy();
+ }
+
+ std::string file_name_;
+ TsFileWriter* tsfile_writer_ = nullptr;
+
+ public:
+ static std::string generate_random_string(int length) {
+ static std::atomic<uint64_t> counter{0};
+ std::mt19937 gen(static_cast<unsigned int>(
+ std::chrono::system_clock::now().time_since_epoch().count()));
+ std::uniform_int_distribution<> dis(0, 61);
+ const std::string chars =
+ "0123456789"
+ "abcdefghijklmnopqrstuvwxyz"
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ std::string random_string;
+ for (int i = 0; i < length; ++i) {
+ random_string += chars[dis(gen)];
+ }
+#ifdef _WIN32
+ const auto process_id = static_cast<uint64_t>(_getpid());
+#else
+ const auto process_id = static_cast<uint64_t>(getpid());
+#endif
+ random_string += "_" + std::to_string(process_id) + "_" +
+ std::to_string(counter.fetch_add(1));
+ return random_string;
+ }
+
+ // Collect per-measurement timeseries index pointers for one device from
+ // the written file. The caller must keep `reader` open while using the
+ // returned pointers (they reference reader-owned arenas).
+ std::map<std::string, storage::ITimeseriesIndex*> CollectMeasurementMeta(
+ storage::TsFileReader& reader, const std::string& device) {
+ std::map<std::string, storage::ITimeseriesIndex*> out;
+ std::vector<std::shared_ptr<IDeviceID>> devices = {
+ std::make_shared<StringArrayDeviceID>(device)};
+ auto meta_map = reader.get_timeseries_metadata(devices);
+ for (auto& dev_pair : meta_map) {
+ for (auto& ts_idx : dev_pair.second) {
+ out[ts_idx->get_measurement_name().to_std_string()] =
+ ts_idx.get();
+ }
+ }
+ return out;
+ }
+};
+
+} // namespace
+
+// Regression (issue #908 bug 1): registering a measurement but never writing
+// data to it used to seal an EMPTY chunk (count=0, dataSize=0) at flush time.
+// Java readers (TsFileSequenceReader self-check / TsFileSketchTool) treat such
+// a file as crashed and refuse to load it. The fix mirrors the aligned
+// branch's hasData() guard: an empty column must produce no chunk at all, so
+// the measurement must be absent from the file's metadata.
+TEST_F(EmptyChunkRegressionTest, NonAlignedRegisteredButEmptyNotSealed) {
+ std::string device = "root.dev_empty_col";
+ const int total_measurements = 3;
+ std::vector<MeasurementSchema> schemas;
+ for (int i = 0; i < total_measurements; i++) {
+ schemas.emplace_back("m" + std::to_string(i), TSDataType::INT32,
+ TSEncoding::PLAIN, CompressionType::UNCOMPRESSED);
+ ASSERT_EQ(
+ tsfile_writer_->register_timeseries(
+ device, MeasurementSchema("m" + std::to_string(i),
+ TSDataType::INT32, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED)),
+ E_OK);
+ }
+
+ // Tablet carries all 3 registered columns, but only m0/m1 receive values.
+ // m2's column stays all-null so its chunk writer never accumulates data.
+ const int rows = 10;
+ storage::Tablet tablet(
+ device, std::make_shared<std::vector<MeasurementSchema>>(schemas),
+ rows);
+ for (int r = 0; r < rows; r++) {
+ ASSERT_EQ(tablet.add_timestamp(r, 1000 + r), E_OK);
+ ASSERT_EQ(tablet.add_value(r, 0u, static_cast<int32_t>(r)), E_OK);
+ ASSERT_EQ(tablet.add_value(r, 1u, static_cast<int32_t>(r * 10)), E_OK);
+ // Column 2 (m2) intentionally never written.
+ }
+ ASSERT_EQ(tsfile_writer_->write_tablet(tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ ASSERT_EQ(tsfile_writer_->close(), E_OK);
+
+ storage::TsFileReader reader;
+ ASSERT_EQ(reader.open(file_name_), E_OK);
+ auto meta = CollectMeasurementMeta(reader, device);
+
+ // Only measurements that received data may appear in the file.
+ ASSERT_EQ(meta.size(), 2u);
+ ASSERT_NE(meta.count("m0"), 0u);
+ ASSERT_NE(meta.count("m1"), 0u);
+ EXPECT_EQ(meta.count("m2"), 0u)
+ << "registered-but-empty measurement must not be sealed as a chunk";
+ // Every surviving series must carry real statistics, not the count=0 /
+ // start=INT64_MAX / end=INT64_MIN signature of a sealed empty chunk.
+ EXPECT_EQ(meta["m0"]->get_statistic()->count_, rows);
+ EXPECT_EQ(meta["m0"]->get_statistic()->start_time_, 1000);
+ EXPECT_EQ(meta["m0"]->get_statistic()->end_time_, 1000 + rows - 1);
+ EXPECT_EQ(meta["m1"]->get_statistic()->count_, rows);
+ reader.close();
+}
+
+// The file produced by the scenario above must also be fully readable through
+// a normal query: the non-empty columns return every row.
+TEST_F(EmptyChunkRegressionTest, NonAlignedEmptyColumnFileIsQueryable) {
+ std::string device = "root.dev_empty_col_query";
+ std::vector<MeasurementSchema> schemas;
+ for (int i = 0; i < 3; i++) {
+ schemas.emplace_back("m" + std::to_string(i), TSDataType::INT32,
+ TSEncoding::PLAIN, CompressionType::UNCOMPRESSED);
+ ASSERT_EQ(
+ tsfile_writer_->register_timeseries(
+ device, MeasurementSchema("m" + std::to_string(i),
+ TSDataType::INT32, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED)),
+ E_OK);
+ }
+ const int rows = 7;
+ storage::Tablet tablet(
+ device, std::make_shared<std::vector<MeasurementSchema>>(schemas),
+ rows);
+ for (int r = 0; r < rows; r++) {
+ ASSERT_EQ(tablet.add_timestamp(r, 100 + r), E_OK);
+ ASSERT_EQ(tablet.add_value(r, 0u, static_cast<int32_t>(r)), E_OK);
+ ASSERT_EQ(tablet.add_value(r, 2u, static_cast<int32_t>(r + 5)), E_OK);
+ }
+ ASSERT_EQ(tsfile_writer_->write_tablet(tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ ASSERT_EQ(tsfile_writer_->close(), E_OK);
+
+ storage::TsFileReader reader;
+ ASSERT_EQ(reader.open(file_name_), E_OK);
+ std::vector<std::string> select_list;
+ select_list.push_back(device + ".m0");
+ select_list.push_back(device + ".m2");
+ storage::ResultSet* tmp_qds = nullptr;
+ ASSERT_EQ(reader.query(select_list, 0, INT64_MAX, tmp_qds), E_OK);
+ auto* qds = (QDSWithoutTimeGenerator*)tmp_qds;
+
+ int row_count = 0;
+ bool has_next = false;
+ while (IS_SUCC(qds->next(has_next)) && has_next) {
+ storage::RowRecord* rec = qds->get_row_record();
+ EXPECT_EQ(rec->get_timestamp(), 100 + row_count);
+ // field(0) is the time column; the selected m0/m2 follow.
+ EXPECT_EQ(rec->get_field(1)->value_.ival_, row_count);
+ EXPECT_EQ(rec->get_field(2)->value_.ival_, row_count + 5);
+ row_count++;
+ }
+ EXPECT_EQ(row_count, rows);
+ reader.destroy_query_data_set(qds);
+ reader.close();
+}
+
+// Variant driven through write_record (non-aligned record path): the same
+// flush_chunk_group code seals the chunk group, so a measurement that never
+// appears in any record must not get an empty chunk either.
+TEST_F(EmptyChunkRegressionTest, NonAlignedEmptyMeasurementRecordPath) {
+ std::string device = "root.dev_empty_col_rec";
+ std::vector<std::string> names = {"s0", "s1"};
+ for (const auto& name : names) {
+ ASSERT_EQ(tsfile_writer_->register_timeseries(
+ device, MeasurementSchema(name, TSDataType::INT64,
+ TSEncoding::PLAIN,
+
CompressionType::UNCOMPRESSED)),
+ E_OK);
+ }
+ for (int i = 0; i < 5; i++) {
+ TsRecord record(1622505600000 + i, device);
+ record.add_point(names[0], static_cast<int64_t>(i));
+ // s1 never appears in any record.
+ ASSERT_EQ(tsfile_writer_->write_record(record), E_OK);
+ }
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ ASSERT_EQ(tsfile_writer_->close(), E_OK);
+
+ storage::TsFileReader reader;
+ ASSERT_EQ(reader.open(file_name_), E_OK);
+ auto meta = CollectMeasurementMeta(reader, device);
+ ASSERT_EQ(meta.size(), 1u);
+ EXPECT_EQ(meta.count("s0"), 1u);
+ EXPECT_EQ(meta.count("s1"), 0u);
+ EXPECT_EQ(meta["s0"]->get_statistic()->count_, 5);
+ reader.close();
+}
+
+// Multi-flush variant: every flush window must skip empty columns, and a
+// measurement that receives data in a later window must survive with a chunk
+// only for that window (never a count=0 chunk for the empty windows).
+TEST_F(EmptyChunkRegressionTest, NonAlignedEmptyMeasurementAcrossFlushes) {
+ std::string device = "root.dev_empty_col_multi";
+ std::vector<MeasurementSchema> schemas;
+ for (int i = 0; i < 3; i++) {
+ schemas.emplace_back("c" + std::to_string(i), TSDataType::INT32,
+ TSEncoding::PLAIN, CompressionType::UNCOMPRESSED);
+ ASSERT_EQ(
+ tsfile_writer_->register_timeseries(
+ device, MeasurementSchema("c" + std::to_string(i),
+ TSDataType::INT32, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED)),
+ E_OK);
+ }
+
+ // Window 1: only c0 written.
+ {
+ storage::Tablet tablet(
+ device, std::make_shared<std::vector<MeasurementSchema>>(schemas),
+ 4);
+ for (int r = 0; r < 4; r++) {
+ ASSERT_EQ(tablet.add_timestamp(r, 1000 + r), E_OK);
+ ASSERT_EQ(tablet.add_value(r, 0u, static_cast<int32_t>(1)), E_OK);
+ }
+ ASSERT_EQ(tsfile_writer_->write_tablet(tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ }
+ // Window 2: only c1 written (c0 gets nothing this window).
+ {
+ storage::Tablet tablet(
+ device, std::make_shared<std::vector<MeasurementSchema>>(schemas),
+ 4);
+ for (int r = 0; r < 4; r++) {
+ ASSERT_EQ(tablet.add_timestamp(r, 2000 + r), E_OK);
+ ASSERT_EQ(tablet.add_value(r, 1u, static_cast<int32_t>(2)), E_OK);
+ }
+ ASSERT_EQ(tsfile_writer_->write_tablet(tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ }
+ ASSERT_EQ(tsfile_writer_->close(), E_OK);
+
+ storage::TsFileReader reader;
+ ASSERT_EQ(reader.open(file_name_), E_OK);
+ auto meta = CollectMeasurementMeta(reader, device);
+ // c2 never written in any window -> absent. c0/c1 each have one chunk.
+ ASSERT_EQ(meta.size(), 2u);
+ EXPECT_EQ(meta.count("c2"), 0u);
+ EXPECT_EQ(meta["c0"]->get_chunk_meta_list()->size(), 1u);
+ EXPECT_EQ(meta["c1"]->get_chunk_meta_list()->size(), 1u);
+ EXPECT_EQ(meta["c0"]->get_statistic()->count_, 4);
+ EXPECT_EQ(meta["c0"]->get_statistic()->start_time_, 1000);
+ EXPECT_EQ(meta["c1"]->get_statistic()->count_, 4);
+ EXPECT_EQ(meta["c1"]->get_statistic()->start_time_, 2000);
+ reader.close();
+}
+
+// Data written after an empty flush window keeps flowing into the same
+// column: an earlier flush that skipped the column must not corrupt the
+// later write (writer reset semantics), and final statistics must span the
+// written window only.
+TEST_F(EmptyChunkRegressionTest, NonAlignedWriteAfterEmptyWindowSurvives) {
+ std::string device = "root.dev_empty_then_write";
+ std::vector<MeasurementSchema> schemas;
+ schemas.emplace_back("w0", TSDataType::INT32, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED);
+ ASSERT_EQ(tsfile_writer_->register_timeseries(
+ device,
+ MeasurementSchema("w0", TSDataType::INT32, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED)),
+ E_OK);
+
+ // Window 1: nothing written, flush must succeed and emit no chunks.
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+
+ // Window 2: real data after the empty flush.
+ const int rows = 6;
+ storage::Tablet tablet(
+ device, std::make_shared<std::vector<MeasurementSchema>>(schemas),
+ rows);
+ for (int r = 0; r < rows; r++) {
+ ASSERT_EQ(tablet.add_timestamp(r, 3000 + r), E_OK);
+ ASSERT_EQ(tablet.add_value(r, 0u, static_cast<int32_t>(r)), E_OK);
+ }
+ ASSERT_EQ(tsfile_writer_->write_tablet(tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ ASSERT_EQ(tsfile_writer_->close(), E_OK);
+
+ storage::TsFileReader reader;
+ ASSERT_EQ(reader.open(file_name_), E_OK);
+ auto meta = CollectMeasurementMeta(reader, device);
+ ASSERT_EQ(meta.size(), 1u);
+ ASSERT_NE(meta.count("w0"), 0u);
+ EXPECT_EQ(meta["w0"]->get_statistic()->count_, rows);
+ EXPECT_EQ(meta["w0"]->get_statistic()->start_time_, 3000);
+ EXPECT_EQ(meta["w0"]->get_statistic()->end_time_, 3000 + rows - 1);
+ reader.close();
+}
+
+// Multiple devices: one device fully written, one device registered with an
+// unwritten measurement. The empty column in the second device must not
+// poison the first device's chunk group (they flush in the same pass).
+TEST_F(EmptyChunkRegressionTest, EmptyColumnDoesNotAffectSiblingDevice) {
+ std::string dev_full = "root.dev_full";
+ std::string dev_partial = "root.dev_partial";
+
+ std::vector<MeasurementSchema> full_schemas;
+ full_schemas.emplace_back("f0", TSDataType::INT32, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED);
+ ASSERT_EQ(tsfile_writer_->register_timeseries(
+ dev_full,
+ MeasurementSchema("f0", TSDataType::INT32, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED)),
+ E_OK);
+
+ std::vector<MeasurementSchema> partial_schemas;
+ partial_schemas.emplace_back("q0", TSDataType::INT32, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED);
+ partial_schemas.emplace_back("q1", TSDataType::INT32, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED);
+ for (const auto& s : partial_schemas) {
+ ASSERT_EQ(tsfile_writer_->register_timeseries(
+ dev_partial,
+ MeasurementSchema(s.measurement_name_, TSDataType::INT32,
+ TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED)),
+ E_OK);
+ }
+
+ const int rows = 5;
+ storage::Tablet full_tablet(
+ dev_full,
+ std::make_shared<std::vector<MeasurementSchema>>(full_schemas), rows);
+ storage::Tablet partial_tablet(
+ dev_partial,
+ std::make_shared<std::vector<MeasurementSchema>>(partial_schemas),
+ rows);
+ for (int r = 0; r < rows; r++) {
+ ASSERT_EQ(full_tablet.add_timestamp(r, 7000 + r), E_OK);
+ ASSERT_EQ(full_tablet.add_value(r, 0u, static_cast<int32_t>(r)), E_OK);
+ ASSERT_EQ(partial_tablet.add_timestamp(r, 7000 + r), E_OK);
+ ASSERT_EQ(partial_tablet.add_value(r, 0u, static_cast<int32_t>(r)),
+ E_OK);
+ }
+ ASSERT_EQ(tsfile_writer_->write_tablet(full_tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->write_tablet(partial_tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ ASSERT_EQ(tsfile_writer_->close(), E_OK);
+
+ storage::TsFileReader reader;
+ ASSERT_EQ(reader.open(file_name_), E_OK);
+ // Query both devices in one get_timeseries_metadata call: the reader
+ // resets its metadata arena per call, so raw pointers from separate
+ // calls must not be held simultaneously.
+ std::vector<std::shared_ptr<IDeviceID>> devices = {
+ std::make_shared<StringArrayDeviceID>(dev_full),
+ std::make_shared<StringArrayDeviceID>(dev_partial)};
+ auto meta_map = reader.get_timeseries_metadata(devices);
+ ASSERT_EQ(meta_map.size(), 2u);
+
+ auto full_list =
+ meta_map.at(std::make_shared<StringArrayDeviceID>(dev_full));
+ ASSERT_EQ(full_list.size(), 1u);
+ EXPECT_EQ(full_list[0]->get_measurement_name().to_std_string(), "f0");
+ EXPECT_EQ(full_list[0]->get_statistic()->count_, rows);
+
+ auto partial_list =
+ meta_map.at(std::make_shared<StringArrayDeviceID>(dev_partial));
+ ASSERT_EQ(partial_list.size(), 1u);
+ EXPECT_EQ(partial_list[0]->get_measurement_name().to_std_string(), "q0");
+ reader.close();
+}
+
+// Mixed aligned/non-aligned devices in one file: an aligned device's value
+// column with no data (all rows null) plus a non-aligned registered-but-empty
+// measurement. Both flush paths must skip their empty columns.
+TEST_F(EmptyChunkRegressionTest, EmptyColumnMixedAlignedAndNonAligned) {
+ std::string dev_aligned = "root.dev_mixed_aligned";
+ std::string dev_plain = "root.dev_mixed_plain";
+
+ // Aligned device: register two value columns, only write one (the other
+ // stays all-null in every tablet).
+ std::vector<MeasurementSchema> aligned_schemas;
+ aligned_schemas.emplace_back("a0", TSDataType::INT64, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED);
+ aligned_schemas.emplace_back("a1", TSDataType::INT64, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED);
+ std::vector<MeasurementSchema*> aligned_reg;
+ for (const auto& s : aligned_schemas) {
+ aligned_reg.push_back(new MeasurementSchema(
+ s.measurement_name_, TSDataType::INT64, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED));
+ }
+ ASSERT_EQ(
+ tsfile_writer_->register_aligned_timeseries(dev_aligned, aligned_reg),
+ E_OK);
+
+ // Non-aligned device: register two measurements, write only one.
+ std::vector<MeasurementSchema> plain_schemas;
+ plain_schemas.emplace_back("p0", TSDataType::INT32, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED);
+ plain_schemas.emplace_back("p1", TSDataType::INT32, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED);
+ for (const auto& s : plain_schemas) {
+ ASSERT_EQ(tsfile_writer_->register_timeseries(
+ dev_plain,
+ MeasurementSchema(s.measurement_name_, TSDataType::INT32,
+ TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED)),
+ E_OK);
+ }
+
+ const int rows = 8;
+ storage::Tablet aligned_tablet(
+ dev_aligned,
+ std::make_shared<std::vector<MeasurementSchema>>(aligned_schemas),
+ rows);
+ storage::Tablet plain_tablet(
+ dev_plain,
+ std::make_shared<std::vector<MeasurementSchema>>(plain_schemas), rows);
+ for (int r = 0; r < rows; r++) {
+ ASSERT_EQ(aligned_tablet.add_timestamp(r, 5000 + r), E_OK);
+ ASSERT_EQ(aligned_tablet.add_value(r, 0u, static_cast<int64_t>(r)),
+ E_OK);
+ // a1 left all-null.
+
+ ASSERT_EQ(plain_tablet.add_timestamp(r, 5000 + r), E_OK);
+ ASSERT_EQ(plain_tablet.add_value(r, 0u, static_cast<int32_t>(r)),
E_OK);
+ // p1 never written.
+ }
+ ASSERT_EQ(tsfile_writer_->write_tablet_aligned(aligned_tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->write_tablet(plain_tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ ASSERT_EQ(tsfile_writer_->close(), E_OK);
+
+ storage::TsFileReader reader;
+ ASSERT_EQ(reader.open(file_name_), E_OK);
+ std::vector<std::shared_ptr<IDeviceID>> devices = {
+ std::make_shared<StringArrayDeviceID>(dev_aligned),
+ std::make_shared<StringArrayDeviceID>(dev_plain)};
+ auto meta_map = reader.get_timeseries_metadata(devices);
+ ASSERT_EQ(meta_map.size(), 2u);
+
+ auto aligned_list =
+ meta_map.at(std::make_shared<StringArrayDeviceID>(dev_aligned));
+ // The aligned branch has always had the hasData() guard; an all-null
+ // value column still counts its (null) rows, so both columns appear.
+ // The point of this test on the aligned side is that flush succeeds and
+ // the written column carries correct statistics.
+ std::map<std::string, storage::ITimeseriesIndex*> aligned_meta;
+ for (auto& ts_idx : aligned_list) {
+ aligned_meta[ts_idx->get_measurement_name().to_std_string()] =
+ ts_idx.get();
+ }
+ ASSERT_EQ(aligned_meta.size(), 2u);
+ EXPECT_EQ(aligned_meta["a0"]->get_statistic()->count_, rows);
+
+ auto plain_list =
+ meta_map.at(std::make_shared<StringArrayDeviceID>(dev_plain));
+ // The non-aligned fix: p1 (registered but never written) must be absent.
+ ASSERT_EQ(plain_list.size(), 1u);
+ EXPECT_EQ(plain_list[0]->get_measurement_name().to_std_string(), "p0");
+ EXPECT_EQ(plain_list[0]->get_statistic()->count_, rows);
+ reader.close();
+}
+
+// ===== Adversarial additions =====
+
+// The memory-threshold auto-flush is a second entry into flush_chunk_group
+// that the tests above never drive (they all flush explicitly). Shrink
+// chunk_group_size_threshold_ so write_tablet() itself triggers the flush,
+// and verify the empty column is still skipped on that path.
+TEST_F(EmptyChunkRegressionTest, EmptyColumnSkippedOnMemoryAutoFlush) {
+ const int64_t prev_threshold =
+ common::g_config_value_.chunk_group_size_threshold_;
+ const int32_t prev_check_interval =
+ common::g_config_value_.record_count_for_next_mem_check_;
+ // Force the next check to fire after the first tablet and flush almost
+ // immediately (threshold below the smallest realistic meta accounting).
+ common::g_config_value_.record_count_for_next_mem_check_ = 1;
+ common::g_config_value_.chunk_group_size_threshold_ = 1;
+
+ std::string device = "root.dev_empty_autoflush";
+ std::vector<MeasurementSchema> schemas;
+ for (int i = 0; i < 3; i++) {
+ schemas.emplace_back("m" + std::to_string(i), TSDataType::INT32,
+ TSEncoding::PLAIN, CompressionType::UNCOMPRESSED);
+ ASSERT_EQ(
+ tsfile_writer_->register_timeseries(
+ device, MeasurementSchema("m" + std::to_string(i),
+ TSDataType::INT32, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED)),
+ E_OK);
+ }
+
+ {
+ const int rows = 5;
+ storage::Tablet tablet(
+ device, std::make_shared<std::vector<MeasurementSchema>>(schemas),
+ rows);
+ for (int r = 0; r < rows; r++) {
+ ASSERT_EQ(tablet.add_timestamp(r, 100 + r), E_OK);
+ ASSERT_EQ(tablet.add_value(r, 0u, static_cast<int32_t>(r)), E_OK);
+ // m1/m2 stay empty for this window.
+ }
+ ASSERT_EQ(tsfile_writer_->write_tablet(tablet), E_OK);
+ }
+
+ common::g_config_value_.chunk_group_size_threshold_ = prev_threshold;
+ common::g_config_value_.record_count_for_next_mem_check_ =
+ prev_check_interval;
+
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ ASSERT_EQ(tsfile_writer_->close(), E_OK);
+
+ storage::TsFileReader reader;
+ ASSERT_EQ(reader.open(file_name_), E_OK);
+ auto meta = CollectMeasurementMeta(reader, device);
+ ASSERT_EQ(meta.size(), 1u);
+ EXPECT_EQ(meta.count("m0"), 1u);
+ EXPECT_EQ(meta.count("m1"), 0u);
+ EXPECT_EQ(meta.count("m2"), 0u);
+ EXPECT_EQ(meta["m0"]->get_statistic()->count_, 5);
+ reader.close();
+}
+
+// Adversarial counter-check for the hasData() guard: a column with *some*
+// nulls (partial data) must still be sealed — the fix must skip only fully
+// empty columns, not partially-null ones. Uses the null-bitmap fallback
+// path in write_column (row 2 of 4 left null).
+TEST_F(EmptyChunkRegressionTest, PartiallyNullColumnIsStillSealed) {
+ std::string device = "root.dev_partial_null";
+ std::vector<MeasurementSchema> schemas;
+ schemas.emplace_back("p0", TSDataType::INT32, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED);
+ schemas.emplace_back("p1", TSDataType::INT32, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED);
+ for (const auto& s : schemas) {
+ ASSERT_EQ(
+ tsfile_writer_->register_timeseries(
+ device, MeasurementSchema(s.measurement_name_,
+ TSDataType::INT32, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED)),
+ E_OK);
+ }
+
+ const int rows = 4;
+ storage::Tablet tablet(
+ device, std::make_shared<std::vector<MeasurementSchema>>(schemas),
+ rows);
+ for (int r = 0; r < rows; r++) {
+ ASSERT_EQ(tablet.add_timestamp(r, 200 + r), E_OK);
+ ASSERT_EQ(tablet.add_value(r, 0u, static_cast<int32_t>(r)), E_OK);
+ if (r != 2) { // row 2 stays null in p1 -> null-bitmap fallback path
+ ASSERT_EQ(tablet.add_value(r, 1u, static_cast<int32_t>(r * 3)),
+ E_OK);
+ }
+ }
+ ASSERT_EQ(tsfile_writer_->write_tablet(tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ ASSERT_EQ(tsfile_writer_->close(), E_OK);
+
+ storage::TsFileReader reader;
+ ASSERT_EQ(reader.open(file_name_), E_OK);
+ auto meta = CollectMeasurementMeta(reader, device);
+ // Both columns sealed; p1 counts its 3 non-null rows.
+ ASSERT_EQ(meta.size(), 2u);
+ ASSERT_NE(meta.count("p1"), 0u)
+ << "hasData() guard must not skip partially-null columns";
+ EXPECT_EQ(meta["p1"]->get_statistic()->count_, rows - 1);
+ EXPECT_EQ(meta["p1"]->get_statistic()->start_time_, 200);
+ EXPECT_EQ(meta["p1"]->get_statistic()->end_time_, 203);
+ reader.close();
+}
+
+// TEXT columns take a different write path (write_string_batch) than the
+// fixed-width columns used above; an empty TEXT column must be skipped too.
+TEST_F(EmptyChunkRegressionTest, EmptyTextColumnNotSealed) {
+ std::string device = "root.dev_empty_text";
+ std::vector<MeasurementSchema> schemas;
+ schemas.emplace_back("t0", TSDataType::TEXT, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED);
+ schemas.emplace_back("t1", TSDataType::TEXT, TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED);
+ for (const auto& s : schemas) {
+ ASSERT_EQ(
+ tsfile_writer_->register_timeseries(
+ device, MeasurementSchema(s.measurement_name_,
TSDataType::TEXT,
+ TSEncoding::PLAIN,
+ CompressionType::UNCOMPRESSED)),
+ E_OK);
+ }
+
+ const int rows = 3;
+ storage::Tablet tablet(
+ device, std::make_shared<std::vector<MeasurementSchema>>(schemas),
+ rows);
+ char buf[] = "v";
+ String s0(buf, 1);
+ for (int r = 0; r < rows; r++) {
+ ASSERT_EQ(tablet.add_timestamp(r, 300 + r), E_OK);
+ ASSERT_EQ(tablet.add_value(r, 0u, s0), E_OK);
+ // t1 never written.
+ }
+ ASSERT_EQ(tsfile_writer_->write_tablet(tablet), E_OK);
+ ASSERT_EQ(tsfile_writer_->flush(), E_OK);
+ ASSERT_EQ(tsfile_writer_->close(), E_OK);
+
+ storage::TsFileReader reader;
+ ASSERT_EQ(reader.open(file_name_), E_OK);
+ auto meta = CollectMeasurementMeta(reader, device);
+ ASSERT_EQ(meta.size(), 1u);
+ EXPECT_EQ(meta.count("t0"), 1u);
+ EXPECT_EQ(meta.count("t1"), 0u)
+ << "empty TEXT column must not be sealed as a chunk";
+ EXPECT_EQ(meta["t0"]->get_statistic()->count_, rows);
+ reader.close();
+}