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 c278e4a06 fix(stream): potential data race between blocking 
XREAD/XREADGROUP and transaction (#3685)
c278e4a06 is described below

commit c278e4a0623bbc1722c9ccfd0b00c96998cdb122
Author: TC <[email protected]>
AuthorDate: Sat Oct 17 10:30:45 2026 -0500

    fix(stream): potential data race between blocking XREAD/XREADGROUP and 
transaction (#3685)
    
    This is the same issue as #2900 / #2910, for the stream commands.
    
    `CommandXRead::OnWrite` and `CommandXReadGroup::OnWrite` replace the
    connection's bufferevent callbacks, as `BlockingCommander` does, but
    they don't take `WorkConcurrencyGuard()` before reading the stream. A
    reader woken by an `XADD` that runs inside `MULTI/EXEC` can therefore
    execute while the `EXEC` still holds the storage in transaction mode. It
    then reads, or for `XREADGROUP` writes, the shared transaction write
    batch from another worker. That corrupts the batch (`Corruption: unknown
    WriteBatch tag` / `WriteBatch has wrong count`).
    
    This change:
    - takes the guard in both `OnWrite` paths, mirroring
    `BlockingCommander::OnWrite`;
    - adds `TestPotentialDataRaceInBlockingStreamRead`, modelled on
    `TestPotentialDataRaceInBlockingCommand`.
    
    Validation on `unstable` @ 07079ab:
    - **The new test:** it fails 5 of 5 runs without the fix and passes 5 of
    5 with it.
    - **Existing suites:** `unit/type/stream`, `unit/multi` and
    `unit/type/list` pass with the fix.
    - **Lint:** clang-format-18, gofmt, go vet and golangci-lint are clean.
    - **Deadlock:** the guard is taken before the key locks, the same order
    `ExecuteCommands` uses. Inside the guarded scope there is only the Range
    read and the Reply. Worker bufferevents use `BEV_OPT_DEFER_CALLBACKS`,
    so nothing re-enters while the guard is held.
    
    It closes #3684.
    
    ### AI assistance
    
    Per the contributing guide: this change was produced with **Claude Code
    (Anthropic, model Claude Opus 5.5)**.
    - **Diagnosis:** we hit the corruption in our own deployment, which uses
    blocked `XREADGROUP` consumers alongside `MULTI/EXEC` batches. Claude
    Code read the KVRocks sources and the RocksDB logs, related them to
    #2900 and #2910, and traced the unguarded `OnWrite` paths in
    `cmd_stream.cc`.
    - **Patch and test:** it wrote both, modelled on #2910.
    - **Validation:** it built `unstable` with and without the patch on our
    test machine, and ran the new test, the existing stream, multi and list
    suites, and the linters.
    
    The results above come from those runs.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
    Co-authored-by: jihuayu <[email protected]>
---
 src/commands/cmd_stream.cc                   |  16 ++
 tests/gocase/unit/type/stream/stream_test.go | 243 +++++++++++++++++++++++++++
 2 files changed, 259 insertions(+)

diff --git a/src/commands/cmd_stream.cc b/src/commands/cmd_stream.cc
index f2facb982..ce8bb3fa2 100644
--- a/src/commands/cmd_stream.cc
+++ b/src/commands/cmd_stream.cc
@@ -1360,6 +1360,14 @@ class CommandXRead : public Commander,
     conn_->SetCB(bev);
     bufferevent_enable(bev, EV_READ);
 
+    // The read below runs from the wake-up callback, outside ExecuteCommands, 
so it
+    // must not overlap an exclusive command: while EXEC runs, the storage is 
in
+    // transaction mode and this read would walk (and, for XREADGROUP, write 
into)
+    // the shared transaction write batch from another worker thread. This 
mirrors
+    // BlockingCommander::OnWrite. For more context, please refer to:
+    // https://github.com/apache/kvrocks/issues/2900
+    auto concurrency = srv_->WorkConcurrencyGuard();
+
     redis::Stream stream_db(srv_->storage, conn_->GetNamespace());
 
     std::vector<StreamReadResult> results;
@@ -1675,6 +1683,14 @@ class CommandXReadGroup : public Commander,
     conn_->SetCB(bev);
     bufferevent_enable(bev, EV_READ);
 
+    // The read below runs from the wake-up callback, outside ExecuteCommands, 
so it
+    // must not overlap an exclusive command: while EXEC runs, the storage is 
in
+    // transaction mode and this read would walk (and, for XREADGROUP, write 
into)
+    // the shared transaction write batch from another worker thread. This 
mirrors
+    // BlockingCommander::OnWrite. For more context, please refer to:
+    // https://github.com/apache/kvrocks/issues/2900
+    auto concurrency = srv_->WorkConcurrencyGuard();
+
     redis::Stream stream_db(srv_->storage, conn_->GetNamespace());
 
     std::vector<StreamReadResult> results;
diff --git a/tests/gocase/unit/type/stream/stream_test.go 
b/tests/gocase/unit/type/stream/stream_test.go
index 7e3b65751..3560ff066 100644
--- a/tests/gocase/unit/type/stream/stream_test.go
+++ b/tests/gocase/unit/type/stream/stream_test.go
@@ -22,11 +22,13 @@ package stream
 
 import (
        "context"
+       "errors"
        "fmt"
        "math/rand"
        "slices"
        "strconv"
        "strings"
+       "sync"
        "testing"
        "time"
 
@@ -2768,3 +2770,244 @@ func parseStreamEntryID(id string) (ts int64, seqNum 
int64) {
 
        return
 }
+
+func TestBlockingXReadWokenByTransaction(t *testing.T) {
+       srv := util.StartServer(t, map[string]string{})
+       defer srv.Close()
+       ctx := context.Background()
+       rdb := srv.NewClient()
+       defer func() { require.NoError(t, rdb.Close()) }()
+
+       const readers = 4
+       clients := make([]*redis.Client, readers)
+       for i := range clients {
+               clients[i] = srv.NewClientWithOption(&redis.Options{MaxRetries: 
-1})
+               defer func() { require.NoError(t, clients[i].Close()) }()
+       }
+
+       for round := 0; round < 4; round++ {
+               results := make(chan *redis.XStreamSliceCmd, readers)
+               for _, client := range clients {
+                       go func() {
+                               results <- client.XRead(ctx, &redis.XReadArgs{
+                                       Streams: []string{"mystream", "$"},
+                                       Count:   1,
+                                       Block:   10 * time.Second,
+                               })
+                       }()
+               }
+               require.Eventually(t, func() bool {
+                       return util.FindInfoEntry(rdb, "blocked_clients") == 
strconv.Itoa(readers)
+               }, 5*time.Second, 10*time.Millisecond)
+
+               pipe := rdb.TxPipeline()
+               value := strconv.Itoa(round)
+               added := pipe.XAdd(ctx, &redis.XAddArgs{Stream: "mystream", 
Values: []string{"field", value}})
+               for i := 0; i < 64; i++ {
+                       pipe.ZAdd(ctx, "myzset", redis.Z{Score: float64(i), 
Member: fmt.Sprintf("member-%d-%d", round, i)})
+               }
+               _, err := pipe.Exec(ctx)
+               require.NoError(t, err)
+               id, err := added.Result()
+               require.NoError(t, err)
+
+               require.Eventually(t, func() bool { return len(results) == 
readers }, 5*time.Second, 10*time.Millisecond)
+               for i := 0; i < readers; i++ {
+                       streams, err := (<-results).Result()
+                       require.NoError(t, err)
+                       require.Equal(t, []redis.XStream{{
+                               Stream:   "mystream",
+                               Messages: []redis.XMessage{{ID: id, Values: 
map[string]interface{}{"field": value}}},
+                       }}, streams)
+               }
+       }
+       require.NoError(t, rdb.Set(ctx, "after", "ok", 0).Err())
+}
+
+func TestBlockingXReadGroupWokenByTransaction(t *testing.T) {
+       srv := util.StartServer(t, map[string]string{})
+       defer srv.Close()
+       ctx := context.Background()
+       rdb := srv.NewClient()
+       defer func() { require.NoError(t, rdb.Close()) }()
+       require.NoError(t, rdb.XGroupCreateMkStream(ctx, "mystream", "mygroup", 
"0").Err())
+
+       const readers = 4
+       const rounds = 4
+       clients := make([]*redis.Client, readers)
+       consumerPending := make(map[string]int64, readers)
+       for i := range clients {
+               clients[i] = srv.NewClientWithOption(&redis.Options{MaxRetries: 
-1})
+               defer func() { require.NoError(t, clients[i].Close()) }()
+               consumerPending[fmt.Sprintf("consumer-%d", i)] = rounds
+       }
+
+       type readResult struct {
+               consumer string
+               cmd      *redis.XStreamSliceCmd
+       }
+       expected := make([]redis.XMessage, 0, readers*rounds)
+       owners := make(map[string]string, readers*rounds)
+       for round := 0; round < rounds; round++ {
+               results := make(chan readResult, readers)
+               for i, client := range clients {
+                       go func() {
+                               consumer := fmt.Sprintf("consumer-%d", i)
+                               results <- readResult{consumer: consumer, cmd: 
client.XReadGroup(ctx, &redis.XReadGroupArgs{
+                                       Group:    "mygroup",
+                                       Consumer: consumer,
+                                       Streams:  []string{"mystream", ">"},
+                                       Count:    1,
+                                       Block:    10 * time.Second,
+                               })}
+                       }()
+               }
+               require.Eventually(t, func() bool {
+                       return util.FindInfoEntry(rdb, "blocked_clients") == 
strconv.Itoa(readers)
+               }, 5*time.Second, 10*time.Millisecond)
+
+               pipe := rdb.TxPipeline()
+               for i := 0; i < readers; i++ {
+                       id := fmt.Sprintf("%d-0", round*readers+i+1)
+                       value := strconv.Itoa(round*readers + i)
+                       pipe.XAdd(ctx, &redis.XAddArgs{Stream: "mystream", ID: 
id, Values: []string{"field", value}})
+                       expected = append(expected, redis.XMessage{ID: id, 
Values: map[string]interface{}{"field": value}})
+               }
+               for i := 0; i < 64; i++ {
+                       pipe.ZAdd(ctx, "myzset", redis.Z{Score: float64(i), 
Member: fmt.Sprintf("member-%d-%d", round, i)})
+               }
+               _, err := pipe.Exec(ctx)
+               require.NoError(t, err)
+
+               require.Eventually(t, func() bool { return len(results) == 
readers }, 5*time.Second, 10*time.Millisecond)
+               messages := make([]redis.XMessage, 0, readers)
+               for i := 0; i < readers; i++ {
+                       result := <-results
+                       streams, err := result.cmd.Result()
+                       require.NoError(t, err)
+                       require.Len(t, streams, 1)
+                       require.Equal(t, "mystream", streams[0].Stream)
+                       require.Len(t, streams[0].Messages, 1)
+                       message := streams[0].Messages[0]
+                       require.NotContains(t, owners, message.ID, "a message 
must only be assigned once")
+                       owners[message.ID] = result.consumer
+                       messages = append(messages, message)
+               }
+               require.ElementsMatch(t, expected[round*readers:], messages)
+       }
+
+       pending, err := rdb.XPending(ctx, "mystream", "mygroup").Result()
+       require.NoError(t, err)
+       require.Equal(t, &redis.XPending{
+               Count:     readers * rounds,
+               Lower:     expected[0].ID,
+               Higher:    expected[len(expected)-1].ID,
+               Consumers: consumerPending,
+       }, pending)
+       entries, err := rdb.XPendingExt(ctx, &redis.XPendingExtArgs{
+               Stream: "mystream",
+               Group:  "mygroup",
+               Start:  "-",
+               End:    "+",
+               Count:  readers*rounds + 1,
+       }).Result()
+       require.NoError(t, err)
+       require.Len(t, entries, len(expected))
+       for i, entry := range entries {
+               require.Equal(t, expected[i].ID, entry.ID)
+               require.Equal(t, owners[entry.ID], entry.Consumer)
+               require.EqualValues(t, 1, entry.RetryCount)
+       }
+
+       groups, err := rdb.XInfoGroups(ctx, "mystream").Result()
+       require.NoError(t, err)
+       require.Len(t, groups, 1)
+       require.Equal(t, "mygroup", groups[0].Name)
+       require.EqualValues(t, readers, groups[0].Consumers)
+       require.EqualValues(t, readers*rounds, groups[0].Pending)
+       require.Equal(t, expected[len(expected)-1].ID, 
groups[0].LastDeliveredID)
+       consumers, err := rdb.XInfoConsumers(ctx, "mystream", 
"mygroup").Result()
+       require.NoError(t, err)
+       require.Len(t, consumers, readers)
+       for _, consumer := range consumers {
+               require.Contains(t, consumerPending, consumer.Name)
+               require.Equal(t, consumerPending[consumer.Name], 
consumer.Pending)
+       }
+       require.NoError(t, rdb.Set(ctx, "after", "ok", 0).Err())
+}
+
+// TestPotentialDataRaceInBlockingStreamRead checks that the wake-up callback 
of a
+// blocked XREAD/XREADGROUP doesn't race with a concurrent transaction: the 
XADD
+// inside MULTI/EXEC wakes the reader while the EXEC still holds the storage in
+// transaction mode, the same shape as TestPotentialDataRaceInBlockingCommand.
+//
+// For more information, please refer to: 
https://github.com/apache/kvrocks/issues/2900
+func TestPotentialDataRaceInBlockingStreamRead(t *testing.T) {
+       srv := util.StartServer(t, map[string]string{})
+       defer srv.Close()
+
+       ctx, cancelFn := context.WithCancel(context.Background())
+       rdb := srv.NewClient()
+       defer func() { require.NoError(t, rdb.Close()) }()
+
+       streamKey := "mystream"
+       require.NoError(t, rdb.Del(ctx, streamKey).Err())
+       require.NoError(t, rdb.XGroupCreateMkStream(ctx, streamKey, "mygroup", 
"$").Err())
+
+       var wg sync.WaitGroup
+       for i := 0; i < 4; i++ {
+               wg.Add(2)
+               go func() {
+                       defer wg.Done()
+                       for {
+                               err := rdb.XRead(ctx, &redis.XReadArgs{
+                                       Streams: []string{streamKey, "$"},
+                                       Count:   10,
+                                       Block:   3500 * time.Millisecond,
+                               }).Err()
+                               if errors.Is(err, redis.Nil) {
+                                       continue
+                               } else if errors.Is(err, context.Canceled) {
+                                       return
+                               } else {
+                                       require.NoError(t, err)
+                               }
+                       }
+               }()
+               go func(consumer string) {
+                       defer wg.Done()
+                       for {
+                               err := rdb.XReadGroup(ctx, 
&redis.XReadGroupArgs{
+                                       Group:    "mygroup",
+                                       Consumer: consumer,
+                                       Streams:  []string{streamKey, ">"},
+                                       Count:    10,
+                                       Block:    3500 * time.Millisecond,
+                               }).Err()
+                               if errors.Is(err, redis.Nil) {
+                                       continue
+                               } else if errors.Is(err, context.Canceled) {
+                                       return
+                               } else {
+                                       require.NoError(t, err)
+                               }
+                       }
+               }(fmt.Sprintf("consumer-%d", i))
+       }
+
+       for i := 0; i < 64; i++ {
+               pipe := rdb.TxPipeline()
+               pipe.XAdd(ctx, &redis.XAddArgs{Stream: streamKey, Values: 
[]string{"field", "value"}})
+               for j := 0; j < 64; j++ {
+                       pipe.ZAdd(ctx, "myzset", redis.Z{Score: float64(j), 
Member: fmt.Sprintf("member-%d-%d", i, j)})
+               }
+               _, err := pipe.Exec(ctx)
+               require.NoError(t, err)
+               time.Sleep(time.Millisecond * 100)
+       }
+       // The server must still accept writes after the transactions.
+       require.NoError(t, rdb.Set(ctx, "after", "ok", 0).Err())
+
+       cancelFn()
+       wg.Wait()
+}

Reply via email to