This is an automated email from the ASF dual-hosted git repository.

git-hulk pushed a commit to branch unstable
in repository https://gitbox.apache.org/repos/asf/kvrocks.git


The following commit(s) were added to refs/heads/unstable by this push:
     new 576f7d580 fix(timeseries): avoid deleting empty key on first write to 
an empty series (#3618)
576f7d580 is described below

commit 576f7d580526b9a60a9070fc092cd6a470422ea3
Author: weimeng <[email protected]>
AuthorDate: Wed Sep 30 17:41:55 2026 +0800

    fix(timeseries): avoid deleting empty key on first write to an empty series 
(#3618)
    
    On the first write to an empty time series (created via `TS.CREATE`, no
    chunk
    persisted yet), `upsertCommonInBatch` emits a spurious `Delete("")`,
    leaving an
    empty-key tombstone in the WAL.
    
    ## Root Cause
    
    When the source series has no persisted chunk, `latest_chunk_key` stays
    `""`,
    but the rename guard only checked `new_key != latest_chunk_key`. Since
    `new_key`
    always carries the key prefix, the condition was unconditionally true
    and ran
    `batch->Delete("")`. The empty `latest_chunk_key` is a valid "source
    empty"
    state — the function already relies on it a few lines below
    (`was_source_empty = latest_chunk_key.empty()`); the delete branch just
    missed
    the same guard.
    
    ## Fix
    
    Add the empty-key precondition:
    
    ```cpp
    if (i == 0 && !latest_chunk_key.empty() && new_key != latest_chunk_key) {
    
    ---------
    
    Co-authored-by: weimeng <[email protected]>
    Co-authored-by: RX Xiao <[email protected]>
---
 src/types/redis_timeseries.cc          |   4 +-
 tests/cppunit/types/timeseries_test.cc | 111 +++++++++++++++++++++++++++++++++
 2 files changed, 114 insertions(+), 1 deletion(-)

diff --git a/src/types/redis_timeseries.cc b/src/types/redis_timeseries.cc
index cebf453dd..7f5a744d1 100644
--- a/src/types/redis_timeseries.cc
+++ b/src/types/redis_timeseries.cc
@@ -965,7 +965,9 @@ rocksdb::Status 
TimeSeries::upsertCommonInBatch(engine::Context &ctx, const Slic
     auto &new_data = new_data_list[i];
     auto new_chunk = CreateTSChunkFromData(new_data);
     auto new_key = internalKeyFromChunkID(ns_key, metadata, 
new_chunk->GetFirstTimestamp());
-    if (i == 0 && new_key != latest_chunk_key) {
+    // latest_chunk_key is empty when the source time series has no persisted 
chunk yet (first
+    // write); skip the delete in that case to avoid emitting a tombstone for 
the empty key.
+    if (i == 0 && !latest_chunk_key.empty() && new_key != latest_chunk_key) {
       s = batch->Delete(latest_chunk_key);
       if (!s.ok()) return s;
     }
diff --git a/tests/cppunit/types/timeseries_test.cc 
b/tests/cppunit/types/timeseries_test.cc
index 5937a9288..f49680842 100644
--- a/tests/cppunit/types/timeseries_test.cc
+++ b/tests/cppunit/types/timeseries_test.cc
@@ -19,6 +19,8 @@
  */
 
 #include <gtest/gtest.h>
+#include <rocksdb/transaction_log.h>
+#include <rocksdb/write_batch.h>
 
 #include <memory>
 #include <random>
@@ -1319,3 +1321,112 @@ TEST_F(TimeSeriesTest, DelComprehensive) {
   };
   check(expected_samples);
 }
+
+// Dedicated fixture with a large write_buffer_size so the WAL is not flushed 
(and
+// therefore not archived/deleted) while the test inspects it. The shared 
TestBase
+// uses write_buffer_size = 1, which flushes on nearly every write and would 
make
+// WAL inspection racy.
+class TimeSeriesWALTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    const char *path = "test_ts_wal.conf";
+    unlink(path);
+    std::ofstream output_file(path, std::ios::out);
+    output_file << "";
+
+    auto s = config_.Load(CLIOptions(path));
+    config_.db_dir = "testdb_ts_wal";
+    config_.rocks_db.compression = rocksdb::CompressionType::kNoCompression;
+    config_.rocks_db.write_buffer_size = 64;
+    storage_ = std::make_unique<engine::Storage>(&config_);
+    s = storage_->Open();
+    ASSERT_TRUE(s.IsOK());
+    ctx_ = std::make_unique<engine::Context>(storage_.get());
+    ts_db_ = std::make_unique<redis::TimeSeries>(storage_.get(), 
"ts_namespace");
+    key_ = "test_ts_wal_key";
+  }
+
+  void TearDown() override {
+    ts_db_.reset();
+    ctx_.reset();
+    storage_.reset();
+    std::error_code ec;
+    std::filesystem::remove_all(config_.db_dir, ec);
+    unlink("test_ts_wal.conf");
+  }
+
+  Config config_;
+  std::unique_ptr<engine::Storage> storage_;
+  std::unique_ptr<engine::Context> ctx_;
+  std::unique_ptr<redis::TimeSeries> ts_db_;
+  std::string key_;
+};
+
+// Regression test: the first write to an empty time series must not emit a 
delete
+// for the empty key. In upsertCommonInBatch, latest_chunk_key stays empty 
when the
+// source series has no persisted chunk yet; the buggy condition still ran
+// batch->Delete(latest_chunk_key) because new_key != "", leaving an empty-key
+// tombstone in the WAL.
+TEST_F(TimeSeriesWALTest, FirstAddToEmptySeriesDoesNotDeleteEmptyKey) {
+  redis::TSCreateOption option;
+  option.retention_time = 3600;
+  option.chunk_size = 1024;
+
+  // An empty time series: metadata exists but no chunk has been persisted.
+  ASSERT_TRUE(ts_db_->Create(*ctx_, key_, option).ok());
+
+  // Sequence right before the first Add, so the WAL scan below covers only 
this Add.
+  auto seq_before_add = storage_->LatestSeqNumber();
+
+  TSSample sample{1000, 12.3};
+  TSChunk::AddResult result;
+  ASSERT_TRUE(ts_db_->Add(*ctx_, key_, sample, option, &result).ok());
+
+  // Scan every WAL batch produced by the Add; none of them may delete the 
empty key.
+  ASSERT_TRUE(storage_->WALHasNewData(seq_before_add + 1));
+  std::unique_ptr<rocksdb::TransactionLogIterator> iter;
+  ASSERT_TRUE(storage_->GetWALIter(seq_before_add + 1, &iter).IsOK());
+
+  // WriteBatch::Iterate aborts as soon as a record hits an un-overridden 
Handler
+  // method (the base class returns InvalidArgument). The first Add emits puts 
(and
+  // possibly range deletes) alongside the delete we care about, so override 
every
+  // mutator as a no-op and only inspect DeleteCF, where the empty-key 
tombstone
+  // would appear.
+  class EmptyKeyDeleteDetector : public rocksdb::WriteBatch::Handler {
+   public:
+    rocksdb::Status PutCF([[maybe_unused]] uint32_t column_family_id, 
[[maybe_unused]] const rocksdb::Slice &key,
+                          [[maybe_unused]] const rocksdb::Slice &value) 
override {
+      return rocksdb::Status::OK();
+    }
+    rocksdb::Status MergeCF([[maybe_unused]] uint32_t column_family_id, 
[[maybe_unused]] const rocksdb::Slice &key,
+                            [[maybe_unused]] const rocksdb::Slice &value) 
override {
+      return rocksdb::Status::OK();
+    }
+    rocksdb::Status SingleDeleteCF([[maybe_unused]] uint32_t column_family_id,
+                                   [[maybe_unused]] const rocksdb::Slice &key) 
override {
+      return rocksdb::Status::OK();
+    }
+    rocksdb::Status DeleteRangeCF([[maybe_unused]] uint32_t column_family_id,
+                                  [[maybe_unused]] const rocksdb::Slice 
&begin_key,
+                                  [[maybe_unused]] const rocksdb::Slice 
&end_key) override {
+      return rocksdb::Status::OK();
+    }
+    rocksdb::Status DeleteCF([[maybe_unused]] uint32_t column_family_id, const 
rocksdb::Slice &key) override {
+      if (key.empty()) found_empty_key_delete_ = true;
+      return rocksdb::Status::OK();
+    }
+    bool FoundEmptyKeyDelete() const { return found_empty_key_delete_; }
+
+   private:
+    bool found_empty_key_delete_ = false;
+  };
+
+  EmptyKeyDeleteDetector detector;
+  while (iter->Valid()) {
+    auto batch = iter->GetBatch();
+    ASSERT_TRUE(batch.writeBatchPtr->Iterate(&detector).ok());
+    iter->Next();
+  }
+
+  EXPECT_FALSE(detector.FoundEmptyKeyDelete()) << "first Add to an empty 
series should not delete the empty key";
+}

Reply via email to