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

jihuayu 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 3c1ac1817 fix(stream): decrement pending_number when XAUTOCLAIM 
removes deleted PEL entries (#3578)
3c1ac1817 is described below

commit 3c1ac18172777cef83bbd6d287c1d28fd4231fba
Author: shitao <[email protected]>
AuthorDate: Tue Aug 11 00:52:47 2026 -0700

    fix(stream): decrement pending_number when XAUTOCLAIM removes deleted PEL 
entries (#3578)
    
    The group's and the owning consumer's `pending_number` can be serialized
    as a RESP integer larger than `INT64_MAX`, which breaks any client that
    decodes integer replies as signed 64-bit — the reply fails to parse and
    the connection is dropped. Hit in production.
    
    ## XAUTOCLAIM `pending_number` drift
    
    When XAUTOCLAIM meets a PEL entry whose stream entry was trimmed or
    XDEL'd, it deletes the PEL entry but never decrements the group's or the
    owning consumer's `pending_number`. The counts drift upward on every
    such sweep and, being unsigned, eventually underflow-wrap on a later
    decrement — after which `XINFO GROUPS` and `XPENDING` report garbage.
    
    The fix mirrors `DeletePelEntries` (XACK): when a deleted entry is
    removed from the PEL, decrement the owning consumer and the group. A
    claim only moves ownership within the group (net-zero for the group
    count), so only deleted entries reduce it. Subtractions are saturating
    so a counter that has already drifted can't wrap.
    
    Test: read entries into a consumer, XDEL them, XAUTOCLAIM, then check
    the group and consumer pending counts return to 0. The existing XDEL
    autoclaim tests only asserted the returned deleted-id list.
    
    > This PR previously also carried an `XINFO GROUPS` lag-underflow fix.
    Per review it is a separate issue (SRP + clean cherry-pick), so I've
    dropped it here and will open a follow-up PR.
    
    ---
    AI assistance: diagnosis and drafting were done with AI help; I've
    reviewed the changes and tests and understand the behavior.
    
    ---------
    
    Signed-off-by: Shitao Weng <[email protected]>
    Co-authored-by: Edward Xu <[email protected]>
---
 src/types/redis_stream.cc                    | 43 ++++++++++++++++++++++++++--
 tests/gocase/unit/type/stream/stream_test.go | 36 +++++++++++++++++++++++
 2 files changed, 76 insertions(+), 3 deletions(-)

diff --git a/src/types/redis_stream.cc b/src/types/redis_stream.cc
index 580629c9f..78ec5cc02 100644
--- a/src/types/redis_stream.cc
+++ b/src/types/redis_stream.cc
@@ -582,6 +582,7 @@ rocksdb::Status Stream::AutoClaim(engine::Context &ctx, 
const Slice &stream_name
 
   auto iter = util::UniqueIterator(ctx, read_options, stream_cf_handle_);
   uint64_t total_claimed_count = 0;
+  std::map<std::string, uint64_t> deleted_consumer_count;
   for (iter->SeekToFirst(); iter->Valid() && count > 0 && attempts > 0; 
iter->Next()) {
     std::string tmp_group_name;
     StreamEntryID entry_id = groupAndEntryIdFromPelInternalKey(iter->key(), 
tmp_group_name);
@@ -605,6 +606,9 @@ rocksdb::Status Stream::AutoClaim(engine::Context &ctx, 
const Slice &stream_name
         deleted_entries.push_back(entry_id);
         s = batch->Delete(stream_cf_handle_, iter->key());
         if (!s.ok()) return s;
+        // The referenced entry was trimmed/XDEL'd; dropping this PEL record 
must
+        // decrement pending_number below, otherwise the counter drifts up and 
wraps.
+        deleted_consumer_count[penl_entry.consumer_name] += 1;
         --count;
         continue;
       }
@@ -636,14 +640,34 @@ rocksdb::Status Stream::AutoClaim(engine::Context &ctx, 
const Slice &stream_name
     }
   }
 
-  if (total_claimed_count > 0 && !pending_entries.empty()) {
+  // A claim keeps the group total (entry moves between consumers); a deleted 
dangling
+  // entry leaves the PEL, so decrement both its consumer and the group. 
Saturating.
+  const uint64_t deleted_count = deleted_entries.size();
+  // The current consumer never appears in claimed_consumer_entity_count (we 
only claim from other
+  // consumers), so keep its own deleted-entry decrement in a scalar and let 
the map hold only the
+  // other consumers, avoiding a find-and-erase on the map.
+  uint64_t current_consumer_decrement = 0;
+  std::map<std::string, uint64_t> consumer_pending_decrements = 
claimed_consumer_entity_count;
+  for (const auto &[consumer, cnt] : deleted_consumer_count) {
+    if (consumer == consumer_name) {
+      current_consumer_decrement += cnt;
+    } else {
+      consumer_pending_decrements[consumer] += cnt;
+    }
+  }
+
+  if (total_claimed_count > 0 || deleted_count > 0) {
     current_consumer_metadata.pending_number += total_claimed_count;
+    current_consumer_metadata.pending_number =
+        current_consumer_metadata.pending_number >= current_consumer_decrement
+            ? current_consumer_metadata.pending_number - 
current_consumer_decrement
+            : 0;
     current_consumer_metadata.last_attempted_interaction_ms = now_ms;
 
     s = batch->Put(stream_cf_handle_, consumer_key, 
encodeStreamConsumerMetadataValue(current_consumer_metadata));
     if (!s.ok()) return s;
 
-    for (const auto &[consumer, count] : claimed_consumer_entity_count) {
+    for (const auto &[consumer, dec] : consumer_pending_decrements) {
       std::string tmp_consumer_key = internalKeyFromConsumerName(ns_key, 
metadata, group_name, consumer);
       std::string tmp_consumer_value;
       s = storage_->Get(ctx, ctx.GetReadOptions(), stream_cf_handle_, 
tmp_consumer_key, &tmp_consumer_value);
@@ -651,10 +675,23 @@ rocksdb::Status Stream::AutoClaim(engine::Context &ctx, 
const Slice &stream_name
         return s;
       }
       StreamConsumerMetadata tmp_consumer_metadata = 
decodeStreamConsumerMetadataValue(tmp_consumer_value);
-      tmp_consumer_metadata.pending_number -= count;
+      tmp_consumer_metadata.pending_number =
+          tmp_consumer_metadata.pending_number >= dec ? 
tmp_consumer_metadata.pending_number - dec : 0;
       s = batch->Put(stream_cf_handle_, tmp_consumer_key, 
encodeStreamConsumerMetadataValue(tmp_consumer_metadata));
       if (!s.ok()) return s;
     }
+
+    if (deleted_count > 0) {
+      std::string group_key = internalKeyFromGroupName(ns_key, metadata, 
group_name);
+      std::string get_group_value;
+      s = storage_->Get(ctx, ctx.GetReadOptions(), stream_cf_handle_, 
group_key, &get_group_value);
+      if (!s.ok()) return s;
+      StreamConsumerGroupMetadata group_metadata = 
decodeStreamConsumerGroupMetadataValue(get_group_value);
+      group_metadata.pending_number =
+          group_metadata.pending_number >= deleted_count ? 
group_metadata.pending_number - deleted_count : 0;
+      s = batch->Put(stream_cf_handle_, group_key, 
encodeStreamConsumerGroupMetadataValue(group_metadata));
+      if (!s.ok()) return s;
+    }
   }
 
   bool has_next_entry = false;
diff --git a/tests/gocase/unit/type/stream/stream_test.go 
b/tests/gocase/unit/type/stream/stream_test.go
index 00354848c..1be57ede2 100644
--- a/tests/gocase/unit/type/stream/stream_test.go
+++ b/tests/gocase/unit/type/stream/stream_test.go
@@ -2155,6 +2155,42 @@ func TestStreamOffset(t *testing.T) {
                // add xpending to this test case when it is supported
        })
 
+       t.Run("XAUTOCLAIM decrements pending_number when sweeping deleted 
entries", func(t *testing.T) {
+               streamName := "x"
+               groupName := "grp"
+               require.NoError(t, rdb.Del(ctx, streamName).Err())
+               for _, id := range []string{"1-0", "2-0", "3-0"} {
+                       require.NoError(t, rdb.XAdd(ctx, &redis.XAddArgs{
+                               Stream: streamName,
+                               ID:     id,
+                               Values: map[string]interface{}{"f": "v"},
+                       }).Err())
+               }
+               require.NoError(t, rdb.XGroupCreate(ctx, streamName, groupName, 
"0").Err())
+
+               // Alice reads all three, so the group and the consumer each 
hold 3 pending.
+               require.NoError(t, rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
+                       Group:    groupName,
+                       Consumer: "Alice",
+                       Count:    10,
+                       Streams:  []string{streamName, ">"},
+               }).Err())
+               require.Equal(t, int64(3), rdb.XInfoGroups(ctx, 
streamName).Val()[0].Pending)
+               consumers := rdb.XInfoConsumers(ctx, streamName, 
groupName).Val()
+               require.Len(t, consumers, 1)
+               require.Equal(t, int64(3), consumers[0].Pending)
+
+               // Delete the entries so their PEL records dangle, then let 
XAUTOCLAIM sweep them.
+               // Before the fix the sweep dropped the records without 
decrementing pending_number.
+               require.NoError(t, rdb.XDel(ctx, streamName, "1-0", "2-0", 
"3-0").Err())
+               require.NoError(t, rdb.Do(ctx, "XAUTOCLAIM", streamName, 
groupName, "Bob", 0, "0-0").Err())
+
+               require.Equal(t, int64(0), rdb.XInfoGroups(ctx, 
streamName).Val()[0].Pending)
+               for _, c := range rdb.XInfoConsumers(ctx, streamName, 
groupName).Val() {
+                       require.Equalf(t, int64(0), c.Pending, "consumer %s 
should have 0 pending", c.Name)
+               }
+       })
+
        t.Run("XAUTOCLAIM with out of range count", func(t *testing.T) {
                err := rdb.XAutoClaim(ctx, &redis.XAutoClaimArgs{
                        Stream:   "x",

Reply via email to