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 15e047ee1 perf(stream): trim with a single DeleteRange instead of 
per-entry Delete (#3592)
15e047ee1 is described below

commit 15e047ee129b4363204382ebb04a7214bdee417d
Author: Alexander Bocharoff <[email protected]>
AuthorDate: Tue Aug 18 03:58:28 2026 -0700

    perf(stream): trim with a single DeleteRange instead of per-entry Delete 
(#3592)
    
    ### Problem
    
    `Stream::trim()` — the shared path behind `XTRIM` and `XADD` with
    `MAXLEN`/`MINID` — removes trimmed entries one at a time: a per-entry
    `WriteBatch::Delete()` in a loop, committed as a single atomic batch.
    This makes trimming **O(entries removed)**:
    
    - Removing a large number of entries builds a correspondingly large
    write batch of point tombstones and commits it in one atomic write.
    - Those point tombstones are then scanned one-by-one by every reader,
    and re-scanned by each subsequent trim's `Seek`.
    
    For a stream that is trimmed continuously (each trim removes many
    entries), this is expensive both on the write and on subsequent
    reads/trims.
    
    ### Change
    
    Replace the per-entry `Delete()` loop with a single
    `WriteBatch::DeleteRange()` over the trimmed span — the same approach
    already used for bulk deletion in `DestroyGroup()`.
    
    - The entries are still scanned to count them, so `metadata` (`size`,
    `first_entry_id`, `max_deleted_entry_id`) is maintained exactly as
    before and `XLEN` stays exact. Only the deletion mechanism changes: the
    write batch stays O(1) (one range tombstone) instead of O(entries
    removed).
    - Group, consumer and PEL subkeys are encoded with a `UINT64_MAX` prefix
    and sort after every entry key, so they always stay outside the deleted
    range. The exclusive upper bound is the first surviving entry for a
    partial trim, or the next-version prefix when the whole stream is
    trimmed.
    
    ### Tests
    
    Adds cases for the behaviour specific to the range delete, alongside the
    existing trim suite:
    
    - consumer group + PEL survival across a full-entry trim,
    - entries added after a trim-to-empty remaining visible (range-tombstone
    sequence-number semantics),
    - repeated `MINID` trims interleaved with adds keeping exact survivors
    and first/max-deleted bookkeeping.
    
    ---------
    
    Co-authored-by: hulk <[email protected]>
---
 src/cluster/batch_sender.cc                        |  10 ++
 src/cluster/batch_sender.h                         |   1 +
 src/cluster/slot_migrate.cc                        |  12 +-
 src/storage/batch_extractor.cc                     |  39 ++++-
 src/storage/iterator.cc                            |  11 ++
 src/types/redis_stream.cc                          |  17 +-
 tests/cppunit/types/stream_test.cc                 | 171 +++++++++++++++++++++
 .../integration/slotmigrate/slotmigrate_test.go    |  38 +++++
 tests/gocase/unit/server/poll_updates_test.go      |  27 ++++
 tests/gocase/unit/type/stream/stream_test.go       |  79 ++++++++++
 10 files changed, 394 insertions(+), 11 deletions(-)

diff --git a/src/cluster/batch_sender.cc b/src/cluster/batch_sender.cc
index e92221ee6..e0617600d 100644
--- a/src/cluster/batch_sender.cc
+++ b/src/cluster/batch_sender.cc
@@ -52,6 +52,16 @@ Status BatchSender::Delete(rocksdb::ColumnFamilyHandle *cf, 
const rocksdb::Slice
   entries_num_++;
   return Status::OK();
 }
+Status BatchSender::DeleteRange(rocksdb::ColumnFamilyHandle *cf, const 
rocksdb::Slice &begin,
+                                const rocksdb::Slice &end) {
+  auto s = write_batch_.DeleteRange(cf, begin, end);
+  if (!s.ok()) {
+    return {Status::NotOK, fmt::format("failed to delete range from migration 
batch, {}", s.ToString())};
+  }
+  pending_entries_++;
+  entries_num_++;
+  return Status::OK();
+}
 
 Status BatchSender::PutLogData(const rocksdb::Slice &blob) {
   auto s = write_batch_.PutLogData(blob);
diff --git a/src/cluster/batch_sender.h b/src/cluster/batch_sender.h
index 07c4e9fdb..39d3e6bce 100644
--- a/src/cluster/batch_sender.h
+++ b/src/cluster/batch_sender.h
@@ -39,6 +39,7 @@ class BatchSender {
 
   Status Put(rocksdb::ColumnFamilyHandle *cf, const rocksdb::Slice &key, const 
rocksdb::Slice &value);
   Status Delete(rocksdb::ColumnFamilyHandle *cf, const rocksdb::Slice &key);
+  Status DeleteRange(rocksdb::ColumnFamilyHandle *cf, const rocksdb::Slice 
&begin, const rocksdb::Slice &end);
   Status PutLogData(const rocksdb::Slice &blob);
   void SetPrefixLogData(const std::string &prefix_logdata);
   Status Send();
diff --git a/src/cluster/slot_migrate.cc b/src/cluster/slot_migrate.cc
index 02e0534c2..28907306b 100644
--- a/src/cluster/slot_migrate.cc
+++ b/src/cluster/slot_migrate.cc
@@ -731,8 +731,16 @@ Status 
SlotMigrator::migrateIncrementalDataByRawKV(uint64_t end_seq, BatchSender
         break;
       }
       case engine::WALItem::Type::kTypeDeleteRange: {
-        // Do nothing in DeleteRange due to it might cross multiple slots. 
It's only used in
-        // FLUSHDB/FLUSHALL commands for now and maybe we can disable them 
while migrating.
+        // The WAL extractor only surfaces range deletes confined to a single 
in-range slot
+        // (e.g. a stream trim), so forwarding is safe; multi-slot ranges 
(FLUSHDB/FLUSHALL) are
+        // dropped before they reach here.
+        if (item.column_family_id > kMaxColumnFamilyID) {
+          INFO("[migrate] Invalid delete-range column family id: {}", 
item.column_family_id);
+          continue;
+        }
+        
GET_OR_RET(batch_sender->DeleteRange(storage_->GetCFHandle(static_cast<ColumnFamilyID>(item.column_family_id)),
+                                             item.key, item.value));
+        break;
       }
       default:
         break;
diff --git a/src/storage/batch_extractor.cc b/src/storage/batch_extractor.cc
index 8dfd4258e..e545b4258 100644
--- a/src/storage/batch_extractor.cc
+++ b/src/storage/batch_extractor.cc
@@ -408,10 +408,41 @@ rocksdb::Status WriteBatchExtractor::DeleteCF(uint32_t 
column_family_id, const S
   return rocksdb::Status::OK();
 }
 
-rocksdb::Status WriteBatchExtractor::DeleteRangeCF([[maybe_unused]] uint32_t 
column_family_id,
-                                                   [[maybe_unused]] const 
Slice &begin_key,
-                                                   [[maybe_unused]] const 
Slice &end_key) {
-  // Do nothing with DeleteRange operations
+rocksdb::Status WriteBatchExtractor::DeleteRangeCF(uint32_t column_family_id, 
const Slice &begin_key,
+                                                   const Slice &end_key) {
+  // The only range delete over user entries is a stream trim 
(redis_stream.cc). Reconstruct the
+  // equivalent command so RESP update consumers stay in sync, mirroring how a 
per-entry stream
+  // delete becomes XDEL. Ranges on other column families (FLUSHDB/FLUSHALL) 
are left alone.
+  if (column_family_id != static_cast<uint32_t>(ColumnFamilyID::Stream)) {
+    return rocksdb::Status::OK();
+  }
+
+  InternalKey begin_ikey(begin_key, is_slot_id_encoded_);
+  Slice begin_sub = begin_ikey.GetSubKey();
+  // Group/consumer/PEL subkeys are UINT64_MAX-prefixed and sort above every 
entry; a trim range
+  // begins at a real entry id, so a UINT64_MAX prefix marks an XGROUP DESTROY 
range to skip.
+  uint64_t begin_marker = 0;
+  GetFixed64(&begin_sub, &begin_marker);
+  if (begin_marker == UINT64_MAX) {
+    return rocksdb::Status::OK();
+  }
+
+  std::string user_key = begin_ikey.GetKey().ToString();
+  InternalKey end_ikey(end_key, is_slot_id_encoded_);
+  std::vector<std::string> command_args;
+  if (end_ikey.GetVersion() != begin_ikey.GetVersion()) {
+    // Full trim: the end bound is the next-version prefix, so every entry was 
removed.
+    command_args = {"XTRIM", user_key, "MAXLEN", "0"};
+  } else {
+    // Partial trim: the end bound is the first surviving entry, so trim 
everything below it.
+    Slice end_sub = end_ikey.GetSubKey();
+    redis::StreamEntryID first_survivor;
+    GetFixed64(&end_sub, &first_survivor.ms);
+    GetFixed64(&end_sub, &first_survivor.seq);
+    command_args = {"XTRIM", user_key, "MINID", first_survivor.ToString()};
+  }
+
+  resp_commands_[""].emplace_back(redis::ArrayOfBulkStrings(command_args));
   return rocksdb::Status::OK();
 }
 
diff --git a/src/storage/iterator.cc b/src/storage/iterator.cc
index e360ffe0c..826a25a0c 100644
--- a/src/storage/iterator.cc
+++ b/src/storage/iterator.cc
@@ -188,6 +188,17 @@ rocksdb::Status WALBatchExtractor::DeleteCF(uint32_t 
column_family_id, const roc
 
 rocksdb::Status WALBatchExtractor::DeleteRangeCF(uint32_t column_family_id, 
const rocksdb::Slice &begin_key,
                                                  const rocksdb::Slice 
&end_key) {
+  if (slot_range_.IsValid()) {
+    // A range delete can be migrated only when it is confined to a single 
in-range slot. A stream
+    // trim (and XGROUP DESTROY) deletes one key's subkeys, so begin/end share 
that key's slot;
+    // a FLUSHDB/FLUSHALL range spans the whole column family across slots and 
cannot be applied
+    // slot-scoped, so drop it as before.
+    auto begin_slot_id = ExtractSlotId(begin_key);
+    auto end_slot_id = ExtractSlotId(end_key);
+    if (begin_slot_id != end_slot_id || !slot_range_.Contains(begin_slot_id)) {
+      return rocksdb::Status::OK();
+    }
+  }
   items_.emplace_back(WALItem::Type::kTypeDeleteRange, column_family_id, 
begin_key.ToString(), end_key.ToString());
   return rocksdb::Status::OK();
 }
diff --git a/src/types/redis_stream.cc b/src/types/redis_stream.cc
index 78ec5cc02..66e05bbda 100644
--- a/src/types/redis_stream.cc
+++ b/src/types/redis_stream.cc
@@ -1679,11 +1679,6 @@ rocksdb::Status Stream::trim(engine::Context &ctx, const 
std::string &ns_key, co
       break;
     }
 
-    auto s = batch->Delete(stream_cf_handle_, iter->key());
-    if (!s.ok()) {
-      return s;
-    }
-
     delete_cnt += 1;
     metadata->size -= 1;
     last_deleted = iter->key().ToString();
@@ -1699,6 +1694,18 @@ rocksdb::Status Stream::trim(engine::Context &ctx, const 
std::string &ns_key, co
     }
   }
 
+  // Remove the counted run with one range tombstone rather than the per-entry 
Delete() above:
+  // the write batch stays O(1) instead of O(entries removed), and readers 
skip the span via the
+  // range-del aggregator instead of stepping over (and re-scanning) N point 
tombstones. The scan
+  // stopped on the first surviving entry, so its key is the exclusive upper 
bound; a full trim
+  // uses the version upper bound. Group/consumer/PEL subkeys sort above 
entries, so either bound
+  // keeps them outside the deleted range.
+  if (delete_cnt > 0) {
+    std::string range_end = iter->Valid() ? iter->key().ToString() : 
next_version_prefix_key;
+    auto s = batch->DeleteRange(stream_cf_handle_, start_key, range_end);
+    if (!s.ok()) return s;
+  }
+
   if (metadata->size == 0) {
     metadata->first_entry_id.Clear();
     metadata->last_entry_id.Clear();
diff --git a/tests/cppunit/types/stream_test.cc 
b/tests/cppunit/types/stream_test.cc
index 96c22c543..9307095cc 100644
--- a/tests/cppunit/types/stream_test.cc
+++ b/tests/cppunit/types/stream_test.cc
@@ -2017,6 +2017,177 @@ TEST_F(RedisStreamTest, 
TrimWithMinIdGreaterThanLastEntryID) {
   EXPECT_EQ(length, 0);
 }
 
+// Trimming every entry away must leave the consumer group, its delivery 
cursor and its
+// pending (PEL) entries intact: the range-delete used by trim stops at the 
first
+// group/consumer/PEL subkey, which sorts above every entry key.
+TEST_F(RedisStreamTest, TrimAllEntriesPreservesConsumerGroupAndPel) {
+  redis::StreamAddOptions add_options;
+  redis::StreamEntryID id1, id2, id3;
+  add_options.next_id_strategy = *ParseNextStreamEntryIDStrategy("1-0");
+  auto s = stream_->Add(*ctx_, name_, add_options, {"k1", "v1"}, &id1);
+  EXPECT_TRUE(s.ok());
+  add_options.next_id_strategy = *ParseNextStreamEntryIDStrategy("2-0");
+  s = stream_->Add(*ctx_, name_, add_options, {"k2", "v2"}, &id2);
+  EXPECT_TRUE(s.ok());
+  add_options.next_id_strategy = *ParseNextStreamEntryIDStrategy("3-0");
+  s = stream_->Add(*ctx_, name_, add_options, {"k3", "v3"}, &id3);
+  EXPECT_TRUE(s.ok());
+
+  std::string group_name = "g1";
+  std::string consumer_name = "c1";
+  redis::StreamXGroupCreateOptions create_options = {false, 0, "0-0"};
+  s = stream_->CreateGroup(*ctx_, name_, create_options, group_name);
+  EXPECT_TRUE(s.ok());
+
+  // Deliver all three entries into the group's PEL.
+  redis::StreamRangeOptions range_options;
+  range_options.start = redis::StreamEntryID::Minimum();
+  range_options.end = redis::StreamEntryID::Maximum();
+  range_options.count = 10;
+  range_options.with_count = true;
+  range_options.exclude_start = true;
+  std::vector<redis::StreamEntry> delivered;
+  s = stream_->RangeWithPending(*ctx_, name_, range_options, &delivered, 
group_name, consumer_name, false, true);
+  EXPECT_TRUE(s.ok());
+  EXPECT_EQ(delivered.size(), 3);
+
+  redis::StreamTrimOptions options;
+  options.strategy = redis::StreamTrimStrategy::MinID;
+  options.min_id = redis::StreamEntryID{4, 0};
+  uint64_t trimmed = 0;
+  s = stream_->Trim(*ctx_, name_, options, &trimmed);
+  EXPECT_TRUE(s.ok());
+  EXPECT_EQ(trimmed, 3);
+
+  uint64_t length = 0;
+  s = stream_->Len(*ctx_, name_, redis::StreamLenOptions{}, &length);
+  EXPECT_TRUE(s.ok());
+  EXPECT_EQ(length, 0);
+
+  std::vector<std::pair<std::string, redis::StreamConsumerGroupMetadata>> 
group_metadata;
+  s = stream_->GetGroupInfo(*ctx_, name_, group_metadata);
+  EXPECT_TRUE(s.ok());
+  EXPECT_EQ(group_metadata.size(), 1);
+  EXPECT_EQ(group_metadata[0].first, group_name);
+  EXPECT_EQ(group_metadata[0].second.pending_number, 3);
+  EXPECT_EQ(group_metadata[0].second.last_delivered_id.ToString(), 
id3.ToString());
+
+  // pending_number above is only the count cached in group metadata; read the 
PEL subkeys
+  // themselves back to prove the range tombstone stopped short of them and 
left every
+  // delivered id in place.
+  redis::StreamPendingOptions pending_options;
+  pending_options.stream_name = name_;
+  pending_options.group_name = group_name;
+  pending_options.start_id = redis::StreamEntryID::Minimum();
+  pending_options.end_id = redis::StreamEntryID::Maximum();
+  pending_options.with_count = true;  // extended form: populate ext_results 
from the PEL subkeys
+  pending_options.count = 10;
+  redis::StreamGetPendingEntryResult pending_infos;
+  std::vector<redis::StreamNACK> ext_results;
+  s = stream_->GetPendingEntries(*ctx_, pending_options, pending_infos, 
ext_results);
+  EXPECT_TRUE(s.ok());
+  EXPECT_EQ(ext_results.size(), 3);
+  EXPECT_EQ(ext_results[0].id.ToString(), id1.ToString());
+  EXPECT_EQ(ext_results[1].id.ToString(), id2.ToString());
+  EXPECT_EQ(ext_results[2].id.ToString(), id3.ToString());
+}
+
+// Entries added after a trim-to-empty carry a higher sequence number than the 
range
+// tombstone, so they must stay visible even though their ids fall inside the 
deleted range.
+TEST_F(RedisStreamTest, TrimToEmptyThenAddedEntriesRemainVisible) {
+  redis::StreamAddOptions add_options;
+  redis::StreamEntryID id;
+  for (const char *ts : {"1-0", "2-0", "3-0"}) {
+    add_options.next_id_strategy = *ParseNextStreamEntryIDStrategy(ts);
+    auto s = stream_->Add(*ctx_, name_, add_options, {"k", "v"}, &id);
+    EXPECT_TRUE(s.ok());
+  }
+
+  redis::StreamTrimOptions options;
+  options.strategy = redis::StreamTrimStrategy::MinID;
+  options.min_id = redis::StreamEntryID{100, 0};
+  uint64_t trimmed = 0;
+  auto s = stream_->Trim(*ctx_, name_, options, &trimmed);
+  EXPECT_TRUE(s.ok());
+  EXPECT_EQ(trimmed, 3);
+
+  redis::StreamEntryID id4, id5;
+  add_options.next_id_strategy = *ParseNextStreamEntryIDStrategy("10-0");
+  s = stream_->Add(*ctx_, name_, add_options, {"k4", "v4"}, &id4);
+  EXPECT_TRUE(s.ok());
+  add_options.next_id_strategy = *ParseNextStreamEntryIDStrategy("11-0");
+  s = stream_->Add(*ctx_, name_, add_options, {"k5", "v5"}, &id5);
+  EXPECT_TRUE(s.ok());
+
+  uint64_t length = 0;
+  s = stream_->Len(*ctx_, name_, redis::StreamLenOptions{}, &length);
+  EXPECT_TRUE(s.ok());
+  EXPECT_EQ(length, 2);
+
+  redis::StreamRangeOptions range_options;
+  range_options.start = redis::StreamEntryID::Minimum();
+  range_options.end = redis::StreamEntryID::Maximum();
+  std::vector<redis::StreamEntry> entries;
+  s = stream_->Range(*ctx_, name_, range_options, &entries);
+  EXPECT_TRUE(s.ok());
+  EXPECT_EQ(entries.size(), 2);
+  EXPECT_EQ(entries[0].key, id4.ToString());
+  CheckStreamEntryValues(entries[0].values, {"k4", "v4"});
+  EXPECT_EQ(entries[1].key, id5.ToString());
+  CheckStreamEntryValues(entries[1].values, {"k5", "v5"});
+}
+
+// Repeated forward MINID trims interleaved with adds must leave exactly the 
surviving
+// entries and correct first/max-deleted bookkeeping.
+TEST_F(RedisStreamTest, RepeatedMinIdTrimKeepsExactSurvivors) {
+  redis::StreamAddOptions add_options;
+  redis::StreamEntryID id;
+  for (const char *ts : {"1-0", "2-0", "3-0", "4-0", "5-0"}) {
+    add_options.next_id_strategy = *ParseNextStreamEntryIDStrategy(ts);
+    auto s = stream_->Add(*ctx_, name_, add_options, {"k", "v"}, &id);
+    EXPECT_TRUE(s.ok());
+  }
+
+  redis::StreamTrimOptions options;
+  options.strategy = redis::StreamTrimStrategy::MinID;
+  options.min_id = redis::StreamEntryID{3, 0};
+  uint64_t trimmed = 0;
+  auto s = stream_->Trim(*ctx_, name_, options, &trimmed);
+  EXPECT_TRUE(s.ok());
+  EXPECT_EQ(trimmed, 2);
+
+  add_options.next_id_strategy = *ParseNextStreamEntryIDStrategy("6-0");
+  s = stream_->Add(*ctx_, name_, add_options, {"k6", "v6"}, &id);
+  EXPECT_TRUE(s.ok());
+
+  options.min_id = redis::StreamEntryID{5, 0};
+  trimmed = 0;
+  s = stream_->Trim(*ctx_, name_, options, &trimmed);
+  EXPECT_TRUE(s.ok());
+  EXPECT_EQ(trimmed, 2);
+
+  uint64_t length = 0;
+  s = stream_->Len(*ctx_, name_, redis::StreamLenOptions{}, &length);
+  EXPECT_TRUE(s.ok());
+  EXPECT_EQ(length, 2);
+
+  redis::StreamRangeOptions range_options;
+  range_options.start = redis::StreamEntryID::Minimum();
+  range_options.end = redis::StreamEntryID::Maximum();
+  std::vector<redis::StreamEntry> entries;
+  s = stream_->Range(*ctx_, name_, range_options, &entries);
+  EXPECT_TRUE(s.ok());
+  EXPECT_EQ(entries.size(), 2);
+  EXPECT_EQ(entries[0].key, (redis::StreamEntryID{5, 0}).ToString());
+  EXPECT_EQ(entries[1].key, (redis::StreamEntryID{6, 0}).ToString());
+
+  redis::StreamInfo info;
+  s = stream_->GetStreamInfo(*ctx_, name_, false, 0, &info);
+  EXPECT_TRUE(s.ok());
+  EXPECT_EQ(info.recorded_first_entry_id.ToString(), (redis::StreamEntryID{5, 
0}).ToString());
+  EXPECT_EQ(info.max_deleted_entry_id.ToString(), (redis::StreamEntryID{4, 
0}).ToString());
+}
+
 TEST_F(RedisStreamTest, StreamInfoOnNonExistingStream) {
   redis::StreamInfo info;
   auto s = stream_->GetStreamInfo(*ctx_, name_, false, 0, &info);
diff --git a/tests/gocase/integration/slotmigrate/slotmigrate_test.go 
b/tests/gocase/integration/slotmigrate/slotmigrate_test.go
index 6e430ce63..6c82bcf14 100644
--- a/tests/gocase/integration/slotmigrate/slotmigrate_test.go
+++ b/tests/gocase/integration/slotmigrate/slotmigrate_test.go
@@ -1118,6 +1118,44 @@ func TestSlotMigrateDataType(t *testing.T) {
                require.EqualValues(t, []string{"element985", "element986", 
"element987", "element988", "element989"},
                        rdb1.LRange(ctx, srcListName, -5, -1).Val())
        })
+
+       t.Run("MIGRATE - XTRIM via parsing WAL logs", func(t *testing.T) {
+               testSlot += 1
+
+               streamName := fmt.Sprintf("stream_{%s}", 
util.SlotTable[testSlot])
+               fillerListName := fmt.Sprintf("list_filler_{%s}", 
util.SlotTable[testSlot])
+               require.NoError(t, rdb0.Del(ctx, streamName).Err())
+               require.NoError(t, rdb0.Del(ctx, fillerListName).Err())
+
+               // Seed the stream so its entries are part of the migration 
snapshot.
+               for i := 1; i <= 10; i++ {
+                       require.NoError(t, rdb0.XAdd(ctx, &redis.XAddArgs{
+                               Stream: streamName, ID: fmt.Sprintf("%d-0", i), 
Values: map[string]interface{}{"f": "v"},
+                       }).Err())
+               }
+
+               // Slow down the migration so the XTRIM below is synced via the 
WAL, where the trim is a
+               // single range tombstone. If that range delete were dropped 
(as it was before), the
+               // destination would keep the trimmed entries while its 
metadata claimed they were gone.
+               require.NoError(t, rdb0.ConfigSet(ctx, "migrate-batch-size-kb", 
"1").Err())
+               require.NoError(t, rdb0.ConfigSet(ctx, 
"migrate-batch-rate-limit-mb", "1").Err())
+               fillerValue := strings.Repeat("a", 512)
+               for i := 0; i < 10000; i++ {
+                       require.NoError(t, rdb0.LPush(ctx, fillerListName, 
fillerValue).Err())
+               }
+
+               require.Equal(t, "OK", rdb0.Do(ctx, "clusterx", "migrate", 
testSlot, id1).Val())
+               requireMigrateState(t, rdb0, testSlot, 
SlotMigrationStateStarted)
+
+               require.NoError(t, rdb0.XTrimMinID(ctx, streamName, 
"6-0").Err())
+               waitForMigrateStateInDuration(t, rdb0, testSlot, 
SlotMigrationStateSuccess, time.Minute)
+
+               require.EqualValues(t, 5, rdb1.XLen(ctx, streamName).Val())
+               items := rdb1.XRange(ctx, streamName, "-", "+").Val()
+               require.Len(t, items, 5)
+               require.EqualValues(t, "6-0", items[0].ID)
+               require.EqualValues(t, "10-0", items[4].ID)
+       })
 }
 
 func TestSlotMigrateDestNotSupportApplyBatch(t *testing.T) {
diff --git a/tests/gocase/unit/server/poll_updates_test.go 
b/tests/gocase/unit/server/poll_updates_test.go
index 9a607e202..287989e20 100644
--- a/tests/gocase/unit/server/poll_updates_test.go
+++ b/tests/gocase/unit/server/poll_updates_test.go
@@ -290,6 +290,33 @@ func TestPollUpdates_WithRESPFormat(t *testing.T) {
                }, pollUpdates.Updates)
        })
 
+       t.Run("Stream trim", func(t *testing.T) {
+               // Trim removes entries with one range tombstone; POLLUPDATES 
must still reconstruct an
+               // equivalent command. A partial trim becomes XTRIM MINID 
<first surviving id>; a trim that
+               // empties the stream becomes XTRIM MAXLEN 0.
+               for _, id := range []string{"1-0", "2-0", "3-0"} {
+                       require.NoError(t, rdb0.XAdd(ctx, &redis.XAddArgs{
+                               Stream: "trimstream", ID: id, Values: 
map[string]interface{}{"f": "v"},
+                       }).Err())
+               }
+               require.NoError(t, rdb0.XTrimMinID(ctx, "trimstream", 
"2-0").Err())
+               require.NoError(t, rdb0.XTrimMaxLen(ctx, "trimstream", 0).Err())
+               result, err := rdb0.Do(ctx, "POLLUPDATES", pollUpdates.NextSeq, 
"MAX", 10, "FORMAT", "RESP").Result()
+               require.NoError(t, err)
+
+               pollUpdates = parsePollUpdatesResult(t, result.(map[any]any), 
true)
+               require.Len(t, pollUpdates.Updates, 1)
+               require.EqualValues(t, []any{RESPFormat{
+                       Commands: [][]string{
+                               {"XADD", "trimstream", "1-0", "f", "v"},
+                               {"XADD", "trimstream", "2-0", "f", "v"},
+                               {"XADD", "trimstream", "3-0", "f", "v"},
+                               {"XTRIM", "trimstream", "MINID", "2-0"},
+                               {"XTRIM", "trimstream", "MAXLEN", "0"},
+                       }},
+               }, pollUpdates.Updates)
+       })
+
        t.Run("JSON type", func(t *testing.T) {
                require.NoError(t, rdb0.JSONSet(ctx, "json", "$", `{"field": 
"value"}`).Err())
                require.NoError(t, rdb0.JSONDel(ctx, "json", "$.field").Err())
diff --git a/tests/gocase/unit/type/stream/stream_test.go 
b/tests/gocase/unit/type/stream/stream_test.go
index 1be57ede2..7e3b65751 100644
--- a/tests/gocase/unit/type/stream/stream_test.go
+++ b/tests/gocase/unit/type/stream/stream_test.go
@@ -650,6 +650,85 @@ var streamTests = func(t *testing.T, configs 
util.KvrocksServerConfigs) {
                require.EqualValues(t, "5-0", items[2].ID)
        })
 
+       t.Run("XTRIM removing every entry keeps the consumer group and its 
PEL", func(t *testing.T) {
+               require.NoError(t, rdb.Del(ctx, "mystream").Err())
+               for i := 1; i <= 3; i++ {
+                       require.NoError(t, rdb.XAdd(ctx, 
&redis.XAddArgs{Stream: "mystream", ID: fmt.Sprintf("%d-0", i), Values: 
[]string{"f", "v"}}).Err())
+               }
+               require.NoError(t, rdb.XGroupCreate(ctx, "mystream", "g1", 
"0").Err())
+               require.NoError(t, rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
+                       Group:    "g1",
+                       Consumer: "c1",
+                       Streams:  []string{"mystream", ">"},
+                       Count:    3,
+               }).Err())
+
+               require.NoError(t, rdb.XTrimMinID(ctx, "mystream", "4-0").Err())
+               require.EqualValues(t, 0, rdb.XLen(ctx, "mystream").Val())
+
+               groups := rdb.XInfoGroups(ctx, "mystream").Val()
+               require.Len(t, groups, 1)
+               require.EqualValues(t, "g1", groups[0].Name)
+               require.EqualValues(t, 3, groups[0].Pending)
+               require.EqualValues(t, "3-0", groups[0].LastDeliveredID)
+
+               // groups[0].Pending above is only the count cached in group 
metadata; read the PEL
+               // subkeys back with XPENDING's extended form to prove the 
entries themselves survived
+               // the range tombstone (which stops at the first 
group/consumer/PEL subkey).
+               pending, err := rdb.XPendingExt(ctx, &redis.XPendingExtArgs{
+                       Stream: "mystream",
+                       Group:  "g1",
+                       Start:  "-",
+                       End:    "+",
+                       Count:  10,
+               }).Result()
+               require.NoError(t, err)
+               require.Len(t, pending, 3)
+               require.EqualValues(t, "1-0", pending[0].ID)
+               require.EqualValues(t, "2-0", pending[1].ID)
+               require.EqualValues(t, "3-0", pending[2].ID)
+       })
+
+       t.Run("XADD after trimming a stream to empty keeps the new entries 
visible", func(t *testing.T) {
+               require.NoError(t, rdb.Del(ctx, "mystream").Err())
+               for i := 1; i <= 3; i++ {
+                       require.NoError(t, rdb.XAdd(ctx, 
&redis.XAddArgs{Stream: "mystream", ID: fmt.Sprintf("%d-0", i), Values: 
[]string{"f", "v"}}).Err())
+               }
+               require.NoError(t, rdb.XTrimMaxLen(ctx, "mystream", 0).Err())
+               require.EqualValues(t, 0, rdb.XLen(ctx, "mystream").Val())
+
+               require.NoError(t, rdb.XAdd(ctx, &redis.XAddArgs{Stream: 
"mystream", ID: "4-0", Values: []string{"f", "v"}}).Err())
+               require.NoError(t, rdb.XAdd(ctx, &redis.XAddArgs{Stream: 
"mystream", ID: "5-0", Values: []string{"f", "v"}}).Err())
+               items := rdb.XRange(ctx, "mystream", "-", "+").Val()
+               require.Len(t, items, 2)
+               require.EqualValues(t, "4-0", items[0].ID)
+               require.EqualValues(t, "5-0", items[1].ID)
+       })
+
+       t.Run("repeated XTRIM MINID interleaved with XADD keeps exact survivors 
and bookkeeping", func(t *testing.T) {
+               require.NoError(t, rdb.Del(ctx, "mystream").Err())
+               for i := 1; i <= 4; i++ {
+                       require.NoError(t, rdb.XAdd(ctx, 
&redis.XAddArgs{Stream: "mystream", ID: fmt.Sprintf("%d-0", i), Values: 
[]string{"f", "v"}}).Err())
+               }
+               require.NoError(t, rdb.XTrimMinID(ctx, "mystream", "3-0").Err())
+               require.EqualValues(t, 2, rdb.XLen(ctx, "mystream").Val())
+
+               for i := 5; i <= 6; i++ {
+                       require.NoError(t, rdb.XAdd(ctx, 
&redis.XAddArgs{Stream: "mystream", ID: fmt.Sprintf("%d-0", i), Values: 
[]string{"f", "v"}}).Err())
+               }
+               require.NoError(t, rdb.XTrimMinID(ctx, "mystream", "5-0").Err())
+
+               items := rdb.XRange(ctx, "mystream", "-", "+").Val()
+               require.Len(t, items, 2)
+               require.EqualValues(t, "5-0", items[0].ID)
+               require.EqualValues(t, "6-0", items[1].ID)
+
+               r := rdb.XInfoStreamFull(ctx, "mystream", 0).Val()
+               require.Equal(t, "5-0", r.RecordedFirstEntryID)
+               require.Equal(t, "4-0", r.MaxDeletedEntryID)
+               require.EqualValues(t, 6, r.EntriesAdded)
+       })
+
        t.Run("XADD with LIMIT consecutive calls", func(t *testing.T) {
                require.NoError(t, rdb.Del(ctx, "mystream").Err())
                for i := 0; i < 100; i++ {

Reply via email to