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

PragmaTwice 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 d2b90da28 test: Add compatibility tests across versions of `kvrocks` 
(#3610)
d2b90da28 is described below

commit d2b90da2838238f3aba1f3a03383a17126bf2aa9
Author: Yatrik S. <[email protected]>
AuthorDate: Thu Sep 17 13:22:12 2026 -0400

    test: Add compatibility tests across versions of `kvrocks` (#3610)
    
    ## What
    
    Adds integration tests validating that kvrocks data files written by
    older releases load and serve correctly on the current code, using
    [testcontainers-go](https://github.com/testcontainers/testcontainers-go)
    (#1372). Old versions run in containers against a shared volume,
    populate real data, then a freshly built `current` image starts on the
    same volume and verifies every key/value survived intact.
    
    ## Compatibility matrix
    
    | Old version | Types exercised | Type first supported in |
    |-------------|-----------------|-------------------------|
    | v2.3.0 | string, hash, list, set, zset, stream, bitmap | — |
    | v2.6.0 | + bloom filter | v2.6.0 |
    | v2.7.0 | + json | v2.7.0 |
    | v2.10.0 | + hyperloglog | v2.10.0 |
    | v2.14.0 | + tdigest | v2.14.0 |
    | v2.15.0 | + timeseries | v2.15.0 (disabled in v2.14.0) |
    
    Each old version is populated only with the types it supports
    (version-aware capability map), then all are verified on the new side.
    
    ## CI
    
    New `compatibility.yml` workflow runs the 6 pairs in parallel: builds
    the PR's own image once (`current`), pre-pulls the published old-version
    images, then each matrix job validates one pair. Runs on PRs touching
    storage/types/ commands/config, on pushes to `unstable`, and on
    releases.
    
    Tests are gated behind a `compat` build tag so the default gocase suite
    in the main CI stays unaffected.
    
    Local verification: full matrix passes (`KVROCKS_OLD_VERSION=v2.x go
    test -tags compat -count=1 -run TestCompatibilityAllTypes
    ./integration/compatibility/`).
    Verified with GHA on fork: https://github.com/yatriks/kvrocks/pull/1
    
    ## Feedback notes
    - Can the CI live in a better place? Downstream of other builds perhaps?
    (Note currently we build the `current` image and pull the rest from
    DockerHub)
    - Does a release checklist need to be updated to add new images to this
    files with release/something we should automate?
    - Please double check the compatibility matrix for correctness, as I
    have inferred from release notes.
    
    ---------
    
    Co-authored-by: Yatrik S. <[email protected]>
    Co-authored-by: Twice <[email protected]>
    Co-authored-by: Twice <[email protected]>
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
---
 .github/workflows/compatibility.yml                |  111 ++
 tests/gocase/go.mod                                |   51 +-
 tests/gocase/go.sum                                |  142 ++-
 .../compatibility/compatibility_test.go            |  163 +++
 tests/gocase/integration/compatibility/testdata.go | 1104 ++++++++++++++++++++
 5 files changed, 1560 insertions(+), 11 deletions(-)

diff --git a/.github/workflows/compatibility.yml 
b/.github/workflows/compatibility.yml
new file mode 100644
index 000000000..43398cfcf
--- /dev/null
+++ b/.github/workflows/compatibility.yml
@@ -0,0 +1,111 @@
+# 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.
+
+name: Compatibility Tests
+
+on:
+  push:
+    branches: [unstable]
+  pull_request:
+    paths:
+      - '.github/workflows/compatibility.yml'
+      - 'Dockerfile'
+      - 'kvrocks.conf'
+      - 'src/**'
+      - 'tests/**'
+  release:
+    types: [published]
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event_name }}-${{ 
github.event.number || github.run_id }}
+  cancel-in-progress: true
+
+jobs:
+  build:
+    name: Build current kvrocks image
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/checkout@v6
+
+      # The old-version images are pulled from Docker Hub by the test; only
+      # the "new" side is built here, once, and handed to the parallel test
+      # jobs. Uses the same buildx method as the nightly image build; push
+      # defaults to false, so the image stays local.
+      - name: Set up Docker Buildx
+        uses: 
docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
+
+      - name: Build current kvrocks image
+        uses: 
docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
+        with:
+          context: .
+          load: true
+          tags: apache/kvrocks:current
+
+      - name: Save image
+        run: docker save apache/kvrocks:current | gzip > 
/tmp/kvrocks-current.tar.gz
+
+      - uses: actions/upload-artifact@v7
+        with:
+          name: kvrocks-current-image
+          path: /tmp/kvrocks-current.tar.gz
+          retention-days: 1
+
+  compatibility-test:
+    name: "${{ matrix.version }} -> current"
+    runs-on: ubuntu-22.04
+    needs: build
+    strategy:
+      fail-fast: false
+      matrix:
+        version: [v2.3.0, v2.6.0, v2.7.0, v2.10.0, v2.14.0, v2.15.0]
+    env:
+      KVROCKS_OLD_VERSION: ${{ matrix.version }}
+      # Test old-version data against the PR's own build, not the released
+      # :latest image, so an encoding regression in the change under test
+      # is caught.
+      KVROCKS_NEW_VERSION: current
+    steps:
+      - uses: actions/checkout@v6
+
+      - uses: actions/download-artifact@v8
+        with:
+          name: kvrocks-current-image
+          path: /tmp
+
+      - name: Load image
+        run: gunzip -c /tmp/kvrocks-current.tar.gz | docker load
+
+      - uses: actions/setup-go@v7
+        with:
+          go-version-file: 'tests/gocase/go.mod'
+          cache: false
+
+      # Pull the old-version image up front so the test does not race a lazy
+      # pull inside the container lifecycle.
+      - name: Pull old kvrocks image
+        run: docker pull docker.io/apache/kvrocks:${KVROCKS_OLD_VERSION#v}
+
+      # Each job runs on a fresh runner, so GOCACHE starts empty and the go
+      # test result cache cannot replay one version's result for another.
+      # KVROCKS_OLD_VERSION is read at runtime and is NOT part of the go test
+      # cache key, so if a shared cache is ever added (actions/cache on
+      # GOCACHE, or collapsing the matrix into a single looping job), version
+      # the cache key or pass -count=1 to go test.
+      - name: Run compatibility test
+        run: |
+          cd tests/gocase
+          go test -tags compat -run TestCompatibilityAllTypes 
./integration/compatibility/
diff --git a/tests/gocase/go.mod b/tests/gocase/go.mod
index b5fb70e01..333c95d4f 100644
--- a/tests/gocase/go.mod
+++ b/tests/gocase/go.mod
@@ -1,27 +1,70 @@
 module github.com/apache/kvrocks/tests/gocase
 
-go 1.25
+go 1.25.0
 
 require (
+       github.com/docker/docker v28.5.2+incompatible
        github.com/redis/go-redis/v9 v9.17.2
-       github.com/shirou/gopsutil/v4 v4.25.12
+       github.com/shirou/gopsutil/v4 v4.26.2
        github.com/stretchr/testify v1.11.1
+       github.com/testcontainers/testcontainers-go v0.41.0
        golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
        golang.org/x/sync v0.19.0
 )
 
 require (
+       dario.cat/mergo v1.0.2 // indirect
+       github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // 
indirect
+       github.com/Microsoft/go-winio v0.6.2 // indirect
+       github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+       github.com/cenkalti/backoff/v5 v5.0.3 // indirect
        github.com/cespare/xxhash/v2 v2.3.0 // indirect
+       github.com/containerd/errdefs v1.0.0 // indirect
+       github.com/containerd/errdefs/pkg v0.3.0 // indirect
+       github.com/containerd/log v0.1.0 // indirect
+       github.com/containerd/platforms v0.2.1 // indirect
+       github.com/cpuguy83/dockercfg v0.3.2 // indirect
        github.com/davecgh/go-spew v1.1.1 // indirect
        github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 
indirect
-       github.com/ebitengine/purego v0.9.1 // indirect
+       github.com/distribution/reference v0.6.0 // indirect
+       github.com/docker/go-connections v0.6.0 // indirect
+       github.com/docker/go-units v0.5.0 // indirect
+       github.com/ebitengine/purego v0.10.0 // indirect
+       github.com/felixge/httpsnoop v1.0.4 // indirect
+       github.com/go-logr/logr v1.4.3 // indirect
+       github.com/go-logr/stdr v1.2.2 // indirect
        github.com/go-ole/go-ole v1.3.0 // indirect
+       github.com/google/uuid v1.6.0 // indirect
+       github.com/klauspost/compress v1.18.2 // indirect
        github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // 
indirect
+       github.com/magiconair/properties v1.8.10 // indirect
+       github.com/moby/docker-image-spec v1.3.1 // indirect
+       github.com/moby/go-archive v0.2.0 // indirect
+       github.com/moby/patternmatcher v0.6.0 // indirect
+       github.com/moby/sys/sequential v0.6.0 // indirect
+       github.com/moby/sys/user v0.4.0 // indirect
+       github.com/moby/sys/userns v0.1.0 // indirect
+       github.com/moby/term v0.5.2 // indirect
+       github.com/morikuni/aec v1.0.0 // indirect
+       github.com/opencontainers/go-digest v1.0.0 // indirect
+       github.com/opencontainers/image-spec v1.1.1 // indirect
+       github.com/pkg/errors v0.9.1 // indirect
        github.com/pmezard/go-difflib v1.0.0 // indirect
        github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 
indirect
+       github.com/sirupsen/logrus v1.9.3 // indirect
        github.com/tklauser/go-sysconf v0.3.16 // indirect
        github.com/tklauser/numcpus v0.11.0 // indirect
        github.com/yusufpapurcu/wmi v1.2.4 // indirect
-       golang.org/x/sys v0.39.0 // indirect
+       go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+       go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 
// indirect
+       go.opentelemetry.io/otel v1.42.0 // indirect
+       go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
+       go.opentelemetry.io/otel/metric v1.42.0 // indirect
+       go.opentelemetry.io/otel/sdk v1.42.0 // indirect
+       go.opentelemetry.io/otel/trace v1.42.0 // indirect
+       go.opentelemetry.io/proto/otlp v1.10.0 // indirect
+       golang.org/x/crypto v0.48.0 // indirect
+       golang.org/x/sys v0.41.0 // indirect
+       google.golang.org/protobuf v1.36.11 // indirect
        gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/tests/gocase/go.sum b/tests/gocase/go.sum
index f1bab7974..19f6a91c0 100644
--- a/tests/gocase/go.sum
+++ b/tests/gocase/go.sum
@@ -1,48 +1,176 @@
+dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
+dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
+github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 
h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
+github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod 
h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c 
h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod 
h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Microsoft/go-winio v0.6.2 
h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod 
h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
 github.com/bsm/ginkgo/v2 v2.12.0 
h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
 github.com/bsm/ginkgo/v2 v2.12.0/go.mod 
h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
 github.com/bsm/gomega v1.27.10/go.mod 
h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/cenkalti/backoff/v4 v4.3.0 
h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod 
h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cenkalti/backoff/v5 v5.0.3 
h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod 
h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
 github.com/cespare/xxhash/v2 v2.3.0 
h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
 github.com/cespare/xxhash/v2 v2.3.0/go.mod 
h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/containerd/errdefs v1.0.0 
h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod 
h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 
h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod 
h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
+github.com/containerd/log v0.1.0 
h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
+github.com/containerd/log v0.1.0/go.mod 
h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
+github.com/containerd/platforms v0.2.1 
h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
+github.com/containerd/platforms v0.2.1/go.mod 
h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
+github.com/cpuguy83/dockercfg v0.3.2 
h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
+github.com/cpuguy83/dockercfg v0.3.2/go.mod 
h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
+github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
+github.com/creack/pty v1.1.18/go.mod 
h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
+github.com/davecgh/go-spew v1.1.0/go.mod 
h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 
h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod 
h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f 
h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod 
h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
-github.com/ebitengine/purego v0.9.1 
h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
-github.com/ebitengine/purego v0.9.1/go.mod 
h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/distribution/reference v0.6.0 
h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod 
h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/docker v28.5.2+incompatible 
h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
+github.com/docker/docker v28.5.2+incompatible/go.mod 
h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.6.0 
h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
+github.com/docker/go-connections v0.6.0/go.mod 
h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
+github.com/docker/go-units v0.5.0 
h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod 
h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/ebitengine/purego v0.10.0 
h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
+github.com/ebitengine/purego v0.10.0/go.mod 
h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/felixge/httpsnoop v1.0.4 
h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod 
h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/go-logr/logr v1.2.2/go.mod 
h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod 
h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod 
h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-ole/go-ole v1.2.6/go.mod 
h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
 github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
 github.com/go-ole/go-ole v1.3.0/go.mod 
h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod 
h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod 
h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 
h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod 
h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
+github.com/klauspost/compress v1.18.2 
h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
+github.com/klauspost/compress v1.18.2/go.mod 
h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod 
h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod 
h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 
h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
 github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod 
h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
+github.com/magiconair/properties v1.8.10 
h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
+github.com/magiconair/properties v1.8.10/go.mod 
h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/moby/docker-image-spec v1.3.1 
h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod 
h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/go-archive v0.2.0 
h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
+github.com/moby/go-archive v0.2.0/go.mod 
h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
+github.com/moby/patternmatcher v0.6.0 
h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
+github.com/moby/patternmatcher v0.6.0/go.mod 
h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
+github.com/moby/sys/atomicwriter v0.1.0 
h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
+github.com/moby/sys/atomicwriter v0.1.0/go.mod 
h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
+github.com/moby/sys/sequential v0.6.0 
h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
+github.com/moby/sys/sequential v0.6.0/go.mod 
h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
+github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
+github.com/moby/sys/user v0.4.0/go.mod 
h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
+github.com/moby/sys/userns v0.1.0 
h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
+github.com/moby/sys/userns v0.1.0/go.mod 
h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
+github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
+github.com/moby/term v0.5.2/go.mod 
h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
+github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
+github.com/morikuni/aec v1.0.0/go.mod 
h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
+github.com/opencontainers/go-digest v1.0.0 
h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod 
h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.1 
h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
+github.com/opencontainers/image-spec v1.1.1/go.mod 
h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod 
h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 
h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod 
h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 
h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
 github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod 
h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
 github.com/redis/go-redis/v9 v9.17.2 
h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
 github.com/redis/go-redis/v9 v9.17.2/go.mod 
h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
-github.com/shirou/gopsutil/v4 v4.25.12 
h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY=
-github.com/shirou/gopsutil/v4 v4.25.12/go.mod 
h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
+github.com/rogpeppe/go-internal v1.14.1 
h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod 
h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/shirou/gopsutil/v4 v4.26.2 
h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
+github.com/shirou/gopsutil/v4 v4.26.2/go.mod 
h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
+github.com/sirupsen/logrus v1.9.3 
h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod 
h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/stretchr/objx v0.1.0/go.mod 
h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod 
h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.7.0/go.mod 
h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.11.1 
h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
 github.com/stretchr/testify v1.11.1/go.mod 
h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/testcontainers/testcontainers-go v0.41.0 
h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais=
+github.com/testcontainers/testcontainers-go v0.41.0/go.mod 
h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI=
 github.com/tklauser/go-sysconf v0.3.16 
h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
 github.com/tklauser/go-sysconf v0.3.16/go.mod 
h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
 github.com/tklauser/numcpus v0.11.0 
h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
 github.com/tklauser/numcpus v0.11.0/go.mod 
h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
 github.com/yusufpapurcu/wmi v1.2.4 
h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
 github.com/yusufpapurcu/wmi v1.2.4/go.mod 
h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+go.opentelemetry.io/auto/sdk v1.2.1 
h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod 
h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 
h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod 
h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
+go.opentelemetry.io/otel v1.42.0 
h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
+go.opentelemetry.io/otel v1.42.0/go.mod 
h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 
h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod 
h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 
h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod 
h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
+go.opentelemetry.io/otel/metric v1.42.0 
h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
+go.opentelemetry.io/otel/metric v1.42.0/go.mod 
h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
+go.opentelemetry.io/otel/sdk v1.42.0 
h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
+go.opentelemetry.io/otel/sdk v1.42.0/go.mod 
h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
+go.opentelemetry.io/otel/trace v1.42.0 
h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
+go.opentelemetry.io/otel/trace v1.42.0/go.mod 
h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
+go.opentelemetry.io/proto/otlp v1.10.0 
h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
+go.opentelemetry.io/proto/otlp v1.10.0/go.mod 
h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod 
h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
 golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 
h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
 golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod 
h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
+golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
+golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
 golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
 golang.org/x/sync v0.19.0/go.mod 
h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod 
h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod 
h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod 
h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod 
h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
-golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 
h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
+golang.org/x/term v0.40.0/go.mod 
h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod 
h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 
h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
+golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod 
h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 
h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
+google.golang.org/genproto/googleapis/api 
v0.0.0-20260209200024-4cfbd4190f57/go.mod 
h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 
h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
+google.golang.org/genproto/googleapis/rpc 
v0.0.0-20260209200024-4cfbd4190f57/go.mod 
h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
+google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
+google.golang.org/grpc v1.79.2/go.mod 
h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+google.golang.org/protobuf v1.36.11 
h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod 
h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod 
h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c 
h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod 
h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod 
h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
diff --git a/tests/gocase/integration/compatibility/compatibility_test.go 
b/tests/gocase/integration/compatibility/compatibility_test.go
new file mode 100644
index 000000000..fbe3338b7
--- /dev/null
+++ b/tests/gocase/integration/compatibility/compatibility_test.go
@@ -0,0 +1,163 @@
+//go:build compat
+
+/*
+ * 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.
+ */
+
+package compatibility
+
+import (
+       "context"
+       "fmt"
+       "io"
+       "os"
+       "strings"
+       "testing"
+       "time"
+
+       dimage "github.com/docker/docker/api/types/image"
+
+       "github.com/redis/go-redis/v9"
+       "github.com/stretchr/testify/require"
+       "github.com/testcontainers/testcontainers-go"
+       "github.com/testcontainers/testcontainers-go/wait"
+)
+
+const (
+       kvrocksPort    = "6666"
+       kvrocksDataDir = "/var/lib/kvrocks/db"
+       defaultVersion = "v2.3.0"
+)
+
+var oldVersion = os.Getenv("KVROCKS_OLD_VERSION")
+var newVersion = os.Getenv("KVROCKS_NEW_VERSION")
+
+func init() {
+       if oldVersion == "" {
+               oldVersion = defaultVersion
+       }
+       if newVersion == "" {
+               newVersion = "latest"
+       }
+}
+
+func startKvrocksContainer(ctx context.Context, t *testing.T, image, 
volumeName string) (testcontainers.Container, error) {
+       client, err := testcontainers.NewDockerClientWithOpts(ctx)
+       if err != nil {
+               return nil, fmt.Errorf("connect to docker: %w", err)
+       }
+       imageInspect, err := client.ImageInspect(ctx, image)
+       if err != nil {
+               // Image not present locally (e.g. fresh CI runner): pull it 
first.
+               // Container creation would pull anyway, but the entrypoint 
rewrite
+               // below needs the image's config.
+               rc, pullErr := client.ImagePull(ctx, image, 
dimage.PullOptions{})
+               if pullErr != nil {
+                       return nil, fmt.Errorf("pull image %s: %w", image, 
pullErr)
+               }
+               _, _ = io.Copy(io.Discard, rc) // consume until the pull 
completes
+               rc.Close()
+               imageInspect, err = client.ImageInspect(ctx, image)
+               if err != nil {
+                       return nil, fmt.Errorf("inspect image %s after pull: 
%w", image, err)
+               }
+       }
+       originalEntrypointCmd := strings.Join(imageInspect.Config.Entrypoint, " 
")
+       workingDir := imageInspect.Config.WorkingDir
+
+       // older images had default user as root; in the entrypoint, we check 
if we're running as uid 999, then:
+       // 1. set ownership of the database mounted volume, as older versions 
will have this directory owned by root,
+       // 2. change user to 999:999 kvrocks
+       preambleCmd := fmt.Sprintf("[ $(id -u) -eq 999 ] && chown -R 999:999 
/var/lib/kvrocks/db && su kvrocks || cd %s", workingDir)
+       // Use /bin/sh: alpine-based images (v2.6.0, v2.7.0) have no bash. Bind 
to
+       // 0.0.0.0: older entrypoints omit --bind and their conf defaults to
+       // 127.0.0.1, unreachable via the published port.
+       entrypoint := []string{"/bin/sh", "-c", preambleCmd + " && " + 
originalEntrypointCmd + " --bind 0.0.0.0"}
+
+       req := testcontainers.ContainerRequest{
+               Image:        image,
+               Entrypoint:   entrypoint,
+               ExposedPorts: []string{fmt.Sprintf("%s/tcp", kvrocksPort)},
+               User:         "root",
+               Mounts: []testcontainers.ContainerMount{
+                       {
+                               Source: 
testcontainers.GenericVolumeMountSource{Name: volumeName},
+                               Target: kvrocksDataDir,
+                       },
+               },
+               WaitingFor: wait.ForListeningPort(kvrocksPort),
+       }
+
+       return testcontainers.GenericContainer(ctx, 
testcontainers.GenericContainerRequest{
+               ContainerRequest: req,
+               Started:          true,
+       })
+}
+
+func terminateContainer(ctx context.Context, c testcontainers.Container, 
volumeName string) {
+       err := c.Terminate(ctx, testcontainers.RemoveVolumes(volumeName))
+       if err != nil {
+               fmt.Printf("Warning: failed to terminate container and remove 
volume: %v\n", err)
+       }
+}
+
+func getContainerAddr(c testcontainers.Container, ctx context.Context, t 
*testing.T) string {
+       hostPort, err := c.MappedPort(ctx, kvrocksPort)
+       require.NoError(t, err)
+       // Host() returns "localhost", which can resolve to ::1 and fail on 
hosts
+       // without IPv6 docker bindings; dial the IPv4 loopback directly.
+       return fmt.Sprintf("127.0.0.1:%s", hostPort.Port())
+}
+
+func TestCompatibilityAllTypes(t *testing.T) {
+       ctx := context.Background()
+
+       // Unique per run: a crashed container leaks its volume, and a reused 
name
+       // would then make every later run fail opening RocksDB (LOCK 
contention).
+       volumeName := fmt.Sprintf("kvrocks-compat-%s-%s-%d", oldVersion, 
t.Name(), time.Now().UnixNano())
+       // Docker Hub tags releases without the 'v' prefix (e.g. 2.3.0, not 
v2.3.0)
+       oldImage := fmt.Sprintf("docker.io/apache/kvrocks:%s", 
strings.TrimPrefix(oldVersion, "v"))
+       // newImage is env-overridable so CI can build the PR's own source and 
test
+       // against it instead of the last released image.
+       newImage := fmt.Sprintf("docker.io/apache/kvrocks:%s", newVersion)
+
+       // Start old kvrocks and populate data
+       oldC, err := startKvrocksContainer(ctx, t, oldImage, volumeName)
+       require.NoError(t, err)
+
+       oldClient := redis.NewClient(&redis.Options{Addr: 
getContainerAddr(oldC, ctx, t)})
+       require.NoError(t, oldClient.Ping(ctx).Err())
+
+       // Populate test data based on old version capabilities
+       testData, err := PopulateTestData(ctx, oldClient, oldVersion)
+       require.NoError(t, err)
+
+       require.NoError(t, oldClient.Close())
+       require.NoError(t, oldC.Terminate(ctx))
+
+       // Start new kvrocks with same volume and verify data
+       newC, err := startKvrocksContainer(ctx, t, newImage, volumeName)
+       require.NoError(t, err)
+       defer terminateContainer(ctx, newC, volumeName)
+
+       newClient := redis.NewClient(&redis.Options{Addr: 
getContainerAddr(newC, ctx, t)})
+       defer func() { require.NoError(t, newClient.Close()) }()
+
+       require.NoError(t, newClient.Ping(ctx).Err())
+       require.NoError(t, VerifyTestData(ctx, newClient, testData))
+}
diff --git a/tests/gocase/integration/compatibility/testdata.go 
b/tests/gocase/integration/compatibility/testdata.go
new file mode 100644
index 000000000..1cbd15e83
--- /dev/null
+++ b/tests/gocase/integration/compatibility/testdata.go
@@ -0,0 +1,1104 @@
+//go:build compat
+
+/*
+ * 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.
+ */
+
+package compatibility
+
+import (
+       "context"
+       "encoding/json"
+       "fmt"
+       "math"
+       "math/rand"
+       "strconv"
+       "strings"
+
+       "github.com/redis/go-redis/v9"
+)
+
+// SeededRandom provides deterministic random generation for reproducible tests
+type SeededRandom struct {
+       rng *rand.Rand
+}
+
+// NewSeededRandom creates a SeededRandom with the given seed
+func NewSeededRandom(seed int64) *SeededRandom {
+       return &SeededRandom{rng: rand.New(rand.NewSource(seed))}
+}
+
+// String generates a random alphanumeric string of the given length
+func (r *SeededRandom) String(length int) string {
+       const charset = 
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+       b := make([]byte, length)
+       for i := range b {
+               b[i] = charset[r.rng.Intn(len(charset))]
+       }
+       return string(b)
+}
+
+// Bytes generates random bytes of the given length
+func (r *SeededRandom) Bytes(length int) []byte {
+       b := make([]byte, length)
+       r.rng.Read(b)
+       return b
+}
+
+// Int63 returns a random int64 in range [0, upper)
+func (r *SeededRandom) Int63(upper int64) int64 {
+       return r.rng.Int63n(upper)
+}
+
+// Int returns a random int in range [0, upper)
+func (r *SeededRandom) Int(upper int) int {
+       return r.rng.Intn(upper)
+}
+
+// Float64 returns a random float64 in [0, 1)
+func (r *SeededRandom) Float64() float64 {
+       return r.rng.Float64()
+}
+
+// TestData contains all populated test data for verification
+type TestData struct {
+       Strings      map[string]string
+       Hashes       map[string]map[string]string
+       Lists        map[string][]string
+       Sets         map[string][]string
+       ZSets        map[string][]ZSetEntry
+       Streams      map[string][]StreamEntry
+       Bitmaps      map[string][]byte
+       BloomFilters map[string][]string
+       JSONs        map[string]string
+       HLLs         map[string][]string
+       TDigests     map[string][]float64
+       TimeSeries   map[string][]TSSample
+}
+
+// TSSample represents a time series sample
+type TSSample struct {
+       Timestamp uint64
+       Value     float64
+}
+
+// ZSetEntry represents a sorted set member
+type ZSetEntry struct {
+       Score  float64
+       Member string
+}
+
+// StreamEntry represents a stream entry
+type StreamEntry struct {
+       ID    string
+       Field string
+       Value string
+}
+
+// Data type identifiers
+const (
+       TypeString      = "string"
+       TypeHash        = "hash"
+       TypeList        = "list"
+       TypeSet         = "set"
+       TypeZSet        = "zset"
+       TypeStream      = "stream"
+       TypeBitmap      = "bitmap"
+       TypeBloomFilter = "bloomfilter"
+       TypeJSON        = "json"
+       TypeHyperLogLog = "hyperloglog"
+       TypeTDigest     = "tdigest"
+       TypeTimeSeries  = "timeseries"
+)
+
+// supportedTypesByVersion maps version strings to supported data types.
+// Verified against the command registration in each release tag (e.g.
+// bf.reserve in v2.6.0, json.set in v2.7.0, pfadd in v2.10.0,
+// tdigest.create in v2.14.0; timeseries exists in v2.14.0 source but its
+// REDIS_REGISTER_COMMANDS is commented out, so it is not listed there and
+// first appears in v2.15.0).
+var supportedTypesByVersion = map[string][]string{
+       "v2.3.0":  {TypeString, TypeHash, TypeList, TypeSet, TypeZSet, 
TypeStream, TypeBitmap},
+       "v2.6.0":  {TypeString, TypeHash, TypeList, TypeSet, TypeZSet, 
TypeStream, TypeBitmap, TypeBloomFilter},
+       "v2.7.0":  {TypeString, TypeHash, TypeList, TypeSet, TypeZSet, 
TypeStream, TypeBitmap, TypeBloomFilter, TypeJSON},
+       "v2.10.0": {TypeString, TypeHash, TypeList, TypeSet, TypeZSet, 
TypeStream, TypeBitmap, TypeBloomFilter, TypeJSON, TypeHyperLogLog},
+       "v2.14.0": {TypeString, TypeHash, TypeList, TypeSet, TypeZSet, 
TypeStream, TypeBitmap, TypeBloomFilter, TypeJSON, TypeHyperLogLog, 
TypeTDigest},
+       "v2.15.0": {TypeString, TypeHash, TypeList, TypeSet, TypeZSet, 
TypeStream, TypeBitmap, TypeBloomFilter, TypeJSON, TypeHyperLogLog, 
TypeTDigest, TypeTimeSeries},
+}
+
+// IsTypeSupported checks if a data type is supported by the given version
+func IsTypeSupported(version string, dataType string) bool {
+       types, ok := supportedTypesByVersion[version]
+       if !ok {
+               return false
+       }
+       for _, t := range types {
+               if t == dataType {
+                       return true
+               }
+       }
+       return false
+}
+
+// PopulateTestData populates all supported data types for the given version
+func PopulateTestData(ctx context.Context, client *redis.Client, version 
string) (*TestData, error) {
+       data := &TestData{
+               Strings:      make(map[string]string),
+               Hashes:       make(map[string]map[string]string),
+               Lists:        make(map[string][]string),
+               Sets:         make(map[string][]string),
+               ZSets:        make(map[string][]ZSetEntry),
+               Streams:      make(map[string][]StreamEntry),
+               Bitmaps:      make(map[string][]byte),
+               BloomFilters: make(map[string][]string),
+               JSONs:        make(map[string]string),
+               HLLs:         make(map[string][]string),
+               TDigests:     make(map[string][]float64),
+               TimeSeries:   make(map[string][]TSSample),
+       }
+
+       // Use version-based seed for determinism
+       seed := int64(12345)
+       r := NewSeededRandom(seed)
+
+       // Populate strings (seed 0)
+       if IsTypeSupported(version, TypeString) {
+               var err error
+               data.Strings, err = populateStrings(ctx, client, r)
+               if err != nil {
+                       return nil, fmt.Errorf("strings: %w", err)
+               }
+       }
+
+       // Populate hashes (seed 1)
+       if IsTypeSupported(version, TypeHash) {
+               r1 := NewSeededRandom(seed + 1)
+               var err error
+               data.Hashes, err = populateHashes(ctx, client, r1)
+               if err != nil {
+                       return nil, fmt.Errorf("hashes: %w", err)
+               }
+       }
+
+       // Populate lists (seed 2)
+       if IsTypeSupported(version, TypeList) {
+               r2 := NewSeededRandom(seed + 2)
+               var err error
+               data.Lists, err = populateLists(ctx, client, r2)
+               if err != nil {
+                       return nil, fmt.Errorf("lists: %w", err)
+               }
+       }
+
+       // Populate sets (seed 3)
+       if IsTypeSupported(version, TypeSet) {
+               r3 := NewSeededRandom(seed + 3)
+               var err error
+               data.Sets, err = populateSets(ctx, client, r3)
+               if err != nil {
+                       return nil, fmt.Errorf("sets: %w", err)
+               }
+       }
+
+       // Populate zsets (seed 4)
+       if IsTypeSupported(version, TypeZSet) {
+               r4 := NewSeededRandom(seed + 4)
+               var err error
+               data.ZSets, err = populateZSets(ctx, client, r4)
+               if err != nil {
+                       return nil, fmt.Errorf("zsets: %w", err)
+               }
+       }
+
+       // Populate streams (seed 5)
+       if IsTypeSupported(version, TypeStream) {
+               r5 := NewSeededRandom(seed + 5)
+               var err error
+               data.Streams, err = populateStreams(ctx, client, r5)
+               if err != nil {
+                       return nil, fmt.Errorf("streams: %w", err)
+               }
+       }
+
+       // Populate bitmaps (seed 6)
+       if IsTypeSupported(version, TypeBitmap) {
+               r6 := NewSeededRandom(seed + 6)
+               var err error
+               data.Bitmaps, err = populateBitmaps(ctx, client, r6)
+               if err != nil {
+                       return nil, fmt.Errorf("bitmaps: %w", err)
+               }
+       }
+
+       // Populate bloom filters (seed 7)
+       if IsTypeSupported(version, TypeBloomFilter) {
+               r7 := NewSeededRandom(seed + 7)
+               var err error
+               data.BloomFilters, err = populateBloomFilters(ctx, client, r7)
+               if err != nil {
+                       return nil, fmt.Errorf("bloom filters: %w", err)
+               }
+       }
+
+       // Populate JSON docs (seed 8)
+       if IsTypeSupported(version, TypeJSON) {
+               r8 := NewSeededRandom(seed + 8)
+               var err error
+               data.JSONs, err = populateJSONs(ctx, client, r8)
+               if err != nil {
+                       return nil, fmt.Errorf("json: %w", err)
+               }
+       }
+
+       // Populate hyperloglogs (seed 9)
+       if IsTypeSupported(version, TypeHyperLogLog) {
+               r9 := NewSeededRandom(seed + 9)
+               var err error
+               data.HLLs, err = populateHLLs(ctx, client, r9)
+               if err != nil {
+                       return nil, fmt.Errorf("hyperloglogs: %w", err)
+               }
+       }
+
+       // Populate tdigests (seed 10)
+       if IsTypeSupported(version, TypeTDigest) {
+               r10 := NewSeededRandom(seed + 10)
+               var err error
+               data.TDigests, err = populateTDigests(ctx, client, r10)
+               if err != nil {
+                       return nil, fmt.Errorf("tdigests: %w", err)
+               }
+       }
+
+       // Populate time series (seed 11)
+       if IsTypeSupported(version, TypeTimeSeries) {
+               r11 := NewSeededRandom(seed + 11)
+               var err error
+               data.TimeSeries, err = populateTimeSeries(ctx, client, r11)
+               if err != nil {
+                       return nil, fmt.Errorf("time series: %w", err)
+               }
+       }
+
+       return data, nil
+}
+
+// VerifyTestData verifies all test data is preserved
+func VerifyTestData(ctx context.Context, client *redis.Client, data *TestData) 
error {
+       seed := int64(12345)
+
+       if data.Strings != nil {
+               r := NewSeededRandom(seed)
+               if err := verifyStrings(ctx, client, data.Strings, r); err != 
nil {
+                       return fmt.Errorf("strings: %w", err)
+               }
+       }
+
+       if data.Hashes != nil {
+               r := NewSeededRandom(seed + 1)
+               if err := verifyHashes(ctx, client, data.Hashes, r); err != nil 
{
+                       return fmt.Errorf("hashes: %w", err)
+               }
+       }
+
+       if data.Lists != nil {
+               r := NewSeededRandom(seed + 2)
+               if err := verifyLists(ctx, client, data.Lists, r); err != nil {
+                       return fmt.Errorf("lists: %w", err)
+               }
+       }
+
+       if data.Sets != nil {
+               r := NewSeededRandom(seed + 3)
+               if err := verifySets(ctx, client, data.Sets, r); err != nil {
+                       return fmt.Errorf("sets: %w", err)
+               }
+       }
+
+       if data.ZSets != nil {
+               r := NewSeededRandom(seed + 4)
+               if err := verifyZSets(ctx, client, data.ZSets, r); err != nil {
+                       return fmt.Errorf("zsets: %w", err)
+               }
+       }
+
+       if data.Streams != nil {
+               r := NewSeededRandom(seed + 5)
+               if err := verifyStreams(ctx, client, data.Streams, r); err != 
nil {
+                       return fmt.Errorf("streams: %w", err)
+               }
+       }
+
+       if data.Bitmaps != nil {
+               r := NewSeededRandom(seed + 6)
+               if err := verifyBitmaps(ctx, client, data.Bitmaps, r); err != 
nil {
+                       return fmt.Errorf("bitmaps: %w", err)
+               }
+       }
+
+       // Maps are always initialized; empty means the old version did not
+       // support the type, so there is nothing to verify.
+       if len(data.BloomFilters) > 0 {
+               r := NewSeededRandom(seed + 7)
+               if err := verifyBloomFilters(ctx, client, data.BloomFilters, 
r); err != nil {
+                       return fmt.Errorf("bloom filters: %w", err)
+               }
+       }
+
+       if len(data.JSONs) > 0 {
+               r := NewSeededRandom(seed + 8)
+               if err := verifyJSONs(ctx, client, data.JSONs, r); err != nil {
+                       return fmt.Errorf("json: %w", err)
+               }
+       }
+
+       if len(data.HLLs) > 0 {
+               r := NewSeededRandom(seed + 9)
+               if err := verifyHLLs(ctx, client, data.HLLs, r); err != nil {
+                       return fmt.Errorf("hyperloglogs: %w", err)
+               }
+       }
+
+       if len(data.TDigests) > 0 {
+               r := NewSeededRandom(seed + 10)
+               if err := verifyTDigests(ctx, client, data.TDigests, r); err != 
nil {
+                       return fmt.Errorf("tdigests: %w", err)
+               }
+       }
+
+       if len(data.TimeSeries) > 0 {
+               r := NewSeededRandom(seed + 11)
+               if err := verifyTimeSeries(ctx, client, data.TimeSeries, r); 
err != nil {
+                       return fmt.Errorf("time series: %w", err)
+               }
+       }
+
+       return nil
+}
+
+// populateStrings creates random string keys and returns what was stored
+func populateStrings(ctx context.Context, client *redis.Client, r 
*SeededRandom) (map[string]string, error) {
+       result := make(map[string]string)
+
+       // Generate N random string keys
+       for i := 0; i < 10; i++ {
+               key := fmt.Sprintf("str:%d", i)
+               value := r.String(64) // 64 char random string
+               if err := client.Set(ctx, key, value, 0).Err(); err != nil {
+                       return nil, fmt.Errorf("set %s: %w", key, err)
+               }
+               result[key] = value
+       }
+
+       return result, nil
+}
+
+// verifyStrings re-generates expected values and compares
+func verifyStrings(ctx context.Context, client *redis.Client, stored 
map[string]string, r *SeededRandom) error {
+       for i := 0; i < 10; i++ {
+               key := fmt.Sprintf("str:%d", i)
+               expected := r.String(64)
+               actual, err := client.Get(ctx, key).Result()
+               if err != nil {
+                       return fmt.Errorf("get %s: %w", key, err)
+               }
+               if actual != expected {
+                       return fmt.Errorf("key %s: expected %q, got %q", key, 
expected, actual)
+               }
+       }
+       return nil
+}
+
+// populateHashes creates random hash keys and returns what was stored
+func populateHashes(ctx context.Context, client *redis.Client, r 
*SeededRandom) (map[string]map[string]string, error) {
+       result := make(map[string]map[string]string)
+
+       for i := 0; i < 5; i++ {
+               key := fmt.Sprintf("hash:%d", i)
+               fields := make(map[string]string)
+               fieldCount := 3 + r.Int(5)
+
+               for j := 0; j < fieldCount; j++ {
+                       field := fmt.Sprintf("f%d", j)
+                       value := r.String(32)
+                       fields[field] = value
+               }
+
+               // Convert to flat args for HSET
+               args := make([]interface{}, 0, len(fields)*2)
+               for k, v := range fields {
+                       args = append(args, k, v)
+               }
+               if err := client.HSet(ctx, key, args...).Err(); err != nil {
+                       return nil, fmt.Errorf("hset %s: %w", key, err)
+               }
+               result[key] = fields
+       }
+
+       return result, nil
+}
+
+// verifyHashes re-generates expected values and compares
+func verifyHashes(ctx context.Context, client *redis.Client, stored 
map[string]map[string]string, r *SeededRandom) error {
+       for i := 0; i < 5; i++ {
+               key := fmt.Sprintf("hash:%d", i)
+               fieldCount := 3 + r.Int(5)
+
+               // Re-generate expected fields
+               fields := make(map[string]string)
+               for j := 0; j < fieldCount; j++ {
+                       field := fmt.Sprintf("f%d", j)
+                       fields[field] = r.String(32)
+               }
+
+               // Compare against stored
+               for f, expVal := range fields {
+                       actVal, err := client.HGet(ctx, key, f).Result()
+                       if err != nil {
+                               return fmt.Errorf("hget %s %s: %w", key, f, err)
+                       }
+                       if actVal != expVal {
+                               return fmt.Errorf("hash %s field %s: expected 
%q, got %q", key, f, expVal, actVal)
+                       }
+               }
+       }
+       return nil
+}
+
+// populateLists creates random list keys and returns what was stored
+func populateLists(ctx context.Context, client *redis.Client, r *SeededRandom) 
(map[string][]string, error) {
+       result := make(map[string][]string)
+
+       for i := 0; i < 5; i++ {
+               key := fmt.Sprintf("list:%d", i)
+               elemCount := 5 + r.Int(20)
+               elems := make([]string, elemCount)
+
+               for j := 0; j < elemCount; j++ {
+                       elems[j] = r.String(32)
+               }
+
+               args := make([]interface{}, len(elems))
+               for j, e := range elems {
+                       args[j] = e
+               }
+               if err := client.RPush(ctx, key, args...).Err(); err != nil {
+                       return nil, fmt.Errorf("rpush %s: %w", key, err)
+               }
+               result[key] = elems
+       }
+
+       return result, nil
+}
+
+// verifyLists re-generates expected values and compares
+func verifyLists(ctx context.Context, client *redis.Client, stored 
map[string][]string, r *SeededRandom) error {
+       for i := 0; i < 5; i++ {
+               key := fmt.Sprintf("list:%d", i)
+               elemCount := 5 + r.Int(20)
+
+               actual, err := client.LRange(ctx, key, 0, -1).Result()
+               if err != nil {
+                       return fmt.Errorf("lrange %s: %w", key, err)
+               }
+
+               for j := 0; j < elemCount; j++ {
+                       expected := r.String(32)
+                       if actual[j] != expected {
+                               return fmt.Errorf("list %s[%d]: expected %q, 
got %q", key, j, expected, actual[j])
+                       }
+               }
+       }
+       return nil
+}
+
+// populateSets creates random set keys and returns what was stored
+func populateSets(ctx context.Context, client *redis.Client, r *SeededRandom) 
(map[string][]string, error) {
+       result := make(map[string][]string)
+
+       for i := 0; i < 5; i++ {
+               key := fmt.Sprintf("set:%d", i)
+               memberCount := 10 + r.Int(30)
+               seen := make(map[string]bool)
+               members := make([]string, 0, memberCount)
+
+               for j := 0; j < memberCount; j++ {
+                       member := r.String(32)
+                       for seen[member] {
+                               member = r.String(32)
+                       }
+                       seen[member] = true
+                       members = append(members, member)
+                       if err := client.SAdd(ctx, key, member).Err(); err != 
nil {
+                               return nil, fmt.Errorf("sadd %s: %w", key, err)
+                       }
+               }
+               result[key] = members
+       }
+
+       return result, nil
+}
+
+// verifySets re-generates expected values and compares (order-independent)
+func verifySets(ctx context.Context, client *redis.Client, stored 
map[string][]string, r *SeededRandom) error {
+       for i := 0; i < 5; i++ {
+               key := fmt.Sprintf("set:%d", i)
+               memberCount := 10 + r.Int(30)
+
+               actual, err := client.SMembers(ctx, key).Result()
+               if err != nil {
+                       return fmt.Errorf("smembers %s: %w", key, err)
+               }
+
+               // Re-generate expected and check membership
+               seen := make(map[string]bool)
+               for j := 0; j < memberCount; j++ {
+                       member := r.String(32)
+                       for seen[member] {
+                               member = r.String(32)
+                       }
+                       seen[member] = true
+                       found := false
+                       for _, a := range actual {
+                               if a == member {
+                                       found = true
+                                       break
+                               }
+                       }
+                       if !found {
+                               return fmt.Errorf("set %s: missing member %q", 
key, member)
+                       }
+               }
+       }
+       return nil
+}
+
+// populateZSets creates random sorted set keys and returns what was stored
+func populateZSets(ctx context.Context, client *redis.Client, r *SeededRandom) 
(map[string][]ZSetEntry, error) {
+       result := make(map[string][]ZSetEntry)
+
+       for i := 0; i < 5; i++ {
+               key := fmt.Sprintf("zset:%d", i)
+               entryCount := 10 + r.Int(30)
+               entries := make([]ZSetEntry, entryCount)
+
+               for j := 0; j < entryCount; j++ {
+                       member := r.String(32)
+                       score := r.Float64() * 1000
+                       entries[j] = ZSetEntry{Score: score, Member: member}
+                       if err := client.ZAdd(ctx, key, redis.Z{Score: score, 
Member: member}).Err(); err != nil {
+                               return nil, fmt.Errorf("zadd %s: %w", key, err)
+                       }
+               }
+               result[key] = entries
+       }
+
+       return result, nil
+}
+
+// verifyZSets re-generates expected values and compares
+func verifyZSets(ctx context.Context, client *redis.Client, stored 
map[string][]ZSetEntry, r *SeededRandom) error {
+       for i := 0; i < 5; i++ {
+               key := fmt.Sprintf("zset:%d", i)
+               entryCount := 10 + r.Int(30)
+
+               actual, err := client.ZRangeWithScores(ctx, key, 0, -1).Result()
+               if err != nil {
+                       return fmt.Errorf("zrange %s: %w", key, err)
+               }
+
+               if len(actual) != entryCount {
+                       return fmt.Errorf("zset %s: expected %d entries, got 
%d", key, entryCount, len(actual))
+               }
+
+               // Re-generate expected entries and build lookup by member
+               expected := make(map[string]float64)
+               for j := 0; j < entryCount; j++ {
+                       member := r.String(32)
+                       score := r.Float64() * 1000
+                       expected[member] = score
+               }
+
+               // Verify all actual entries match expected
+               for _, z := range actual {
+                       member, _ := z.Member.(string)
+                       expScore, ok := expected[member]
+                       if !ok {
+                               return fmt.Errorf("zset %s: unexpected member 
%q", key, member)
+                       }
+                       if z.Score != expScore {
+                               return fmt.Errorf("zset %s member %q: expected 
score %.2f, got %.2f",
+                                       key, member, expScore, z.Score)
+                       }
+               }
+       }
+       return nil
+}
+
+// populateStreams creates random stream keys and returns what was stored
+func populateStreams(ctx context.Context, client *redis.Client, r 
*SeededRandom) (map[string][]StreamEntry, error) {
+       result := make(map[string][]StreamEntry)
+
+       for i := 0; i < 3; i++ {
+               key := fmt.Sprintf("stream:%d", i)
+               entryCount := 5 + r.Int(15)
+               entries := make([]StreamEntry, entryCount)
+
+               for j := 0; j < entryCount; j++ {
+                       field := fmt.Sprintf("field%d", j)
+                       value := r.String(32)
+                       entries[j] = StreamEntry{Field: field, Value: value}
+
+                       id, err := client.XAdd(ctx, &redis.XAddArgs{
+                               Stream: key,
+                               Values: map[string]interface{}{field: value},
+                       }).Result()
+                       if err != nil {
+                               return nil, fmt.Errorf("xadd %s: %w", key, err)
+                       }
+                       entries[j].ID = id
+               }
+               result[key] = entries
+       }
+
+       return result, nil
+}
+
+// verifyStreams re-generates expected values and compares
+func verifyStreams(ctx context.Context, client *redis.Client, stored 
map[string][]StreamEntry, r *SeededRandom) error {
+       for i := 0; i < 3; i++ {
+               key := fmt.Sprintf("stream:%d", i)
+               entryCount := 5 + r.Int(15)
+
+               actual, err := client.XRange(ctx, key, "-", "+").Result()
+               if err != nil {
+                       return fmt.Errorf("xrange %s: %w", key, err)
+               }
+
+               for j := 0; j < entryCount; j++ {
+                       field := fmt.Sprintf("field%d", j)
+                       expected := r.String(32)
+                       if actual[j].Values[field] != expected {
+                               return fmt.Errorf("stream %s[%d]: expected 
%s=%q, got %v",
+                                       key, j, field, expected, 
actual[j].Values)
+                       }
+               }
+       }
+       return nil
+}
+
+// populateBitmaps creates random bitmap keys and returns what was stored
+func populateBitmaps(ctx context.Context, client *redis.Client, r 
*SeededRandom) (map[string][]byte, error) {
+       result := make(map[string][]byte)
+
+       for i := 0; i < 5; i++ {
+               key := fmt.Sprintf("bitmap:%d", i)
+               size := 64 + r.Int(256) // 64-320 bytes
+               bytes := r.Bytes(size)
+               if err := client.Set(ctx, key, string(bytes), 0).Err(); err != 
nil {
+                       return nil, fmt.Errorf("set %s: %w", key, err)
+               }
+               result[key] = bytes
+       }
+
+       return result, nil
+}
+
+// verifyBitmaps re-generates expected values and compares
+func verifyBitmaps(ctx context.Context, client *redis.Client, stored 
map[string][]byte, r *SeededRandom) error {
+       for i := 0; i < 5; i++ {
+               key := fmt.Sprintf("bitmap:%d", i)
+               size := 64 + r.Int(256)
+               expected := r.Bytes(size)
+
+               actual, err := client.Get(ctx, key).Result()
+               if err != nil {
+                       return fmt.Errorf("get %s: %w", key, err)
+               }
+               if actual != string(expected) {
+                       return fmt.Errorf("bitmap %s: mismatch", key)
+               }
+       }
+       return nil
+}
+
+// populateBloomFilters adds random members to bloom filter keys
+func populateBloomFilters(ctx context.Context, client *redis.Client, r 
*SeededRandom) (map[string][]string, error) {
+       result := make(map[string][]string)
+
+       for i := 0; i < 3; i++ {
+               key := fmt.Sprintf("bf:%d", i)
+               memberCount := 20 + r.Int(50)
+               members := make([]string, memberCount)
+
+               for j := 0; j < memberCount; j++ {
+                       members[j] = fmt.Sprintf("m%d-%s", j, r.String(16))
+                       if err := client.BFAdd(ctx, key, members[j]).Err(); err 
!= nil {
+                               return nil, fmt.Errorf("bf.add %s: %w", key, 
err)
+                       }
+               }
+               result[key] = members
+       }
+
+       return result, nil
+}
+
+// verifyBloomFilters checks every populated member is still reported present
+func verifyBloomFilters(ctx context.Context, client *redis.Client, stored 
map[string][]string, r *SeededRandom) error {
+       for i := 0; i < 3; i++ {
+               key := fmt.Sprintf("bf:%d", i)
+               memberCount := 20 + r.Int(50)
+
+               for j := 0; j < memberCount; j++ {
+                       member := fmt.Sprintf("m%d-%s", j, r.String(16))
+                       present, err := client.BFExists(ctx, key, 
member).Result()
+                       if err != nil {
+                               return fmt.Errorf("bf.exists %s %s: %w", key, 
member, err)
+                       }
+                       if !present {
+                               return fmt.Errorf("bf %s: member %q lost", key, 
member)
+                       }
+               }
+       }
+       return nil
+}
+
+// populateJSONs stores random nested JSON documents
+func populateJSONs(ctx context.Context, client *redis.Client, r *SeededRandom) 
(map[string]string, error) {
+       result := make(map[string]string)
+
+       for i := 0; i < 3; i++ {
+               key := fmt.Sprintf("json:%d", i)
+               doc := map[string]interface{}{
+                       "name":   r.String(16),
+                       "age":    r.Int(100),
+                       "tags":   []string{r.String(8), r.String(8)},
+                       "nested": map[string]interface{}{"flag": r.Int(2) == 0},
+               }
+               raw, err := json.Marshal(doc)
+               if err != nil {
+                       return nil, fmt.Errorf("marshal json %s: %w", key, err)
+               }
+               if err := client.JSONSet(ctx, key, "$", doc).Err(); err != nil {
+                       return nil, fmt.Errorf("json.set %s: %w", key, err)
+               }
+               result[key] = string(raw)
+       }
+
+       return result, nil
+}
+
+// verifyJSONs re-generates expected docs and compares, then checks JSON.DEL
+func verifyJSONs(ctx context.Context, client *redis.Client, stored 
map[string]string, r *SeededRandom) error {
+       for i := 0; i < 3; i++ {
+               key := fmt.Sprintf("json:%d", i)
+               doc := map[string]interface{}{
+                       "name":   r.String(16),
+                       "age":    r.Int(100),
+                       "tags":   []string{r.String(8), r.String(8)},
+                       "nested": map[string]interface{}{"flag": r.Int(2) == 0},
+               }
+               expected, err := json.Marshal(doc)
+               if err != nil {
+                       return fmt.Errorf("marshal expected %s: %w", key, err)
+               }
+               actual, err := client.JSONGet(ctx, key).Result()
+               if err != nil {
+                       return fmt.Errorf("json.get %s: %w", key, err)
+               }
+               if actual != string(expected) {
+                       return fmt.Errorf("json %s: expected %s, got %s", key, 
expected, actual)
+               }
+
+               deleted, err := client.Do(ctx, "JSON.DEL", key, "$").Int64()
+               if err != nil {
+                       return fmt.Errorf("json.del %s: %w", key, err)
+               }
+               if deleted != 1 {
+                       return fmt.Errorf("json.del %s: expected 1, got %d", 
key, deleted)
+               }
+       }
+       return nil
+}
+
+// hllCountTolerance is generous: HLL counts are estimates with ~0.81% error.
+const hllCountTolerance = 0.05
+const hllMergeTolerance = 0.10
+
+// populateHLLs adds many unique members to two keys (disjoint prefixes)
+func populateHLLs(ctx context.Context, client *redis.Client, r *SeededRandom) 
(map[string][]string, error) {
+       result := make(map[string][]string)
+
+       for i := 0; i < 2; i++ {
+               key := fmt.Sprintf("hll:%d", i)
+               memberCount := 1000 + r.Int(2000)
+               members := make([]string, memberCount)
+
+               for j := 0; j < memberCount; j++ {
+                       members[j] = fmt.Sprintf("h%d-%s", i, r.String(24))
+                       if err := client.PFAdd(ctx, key, members[j]).Err(); err 
!= nil {
+                               return nil, fmt.Errorf("pfadd %s: %w", key, err)
+                       }
+               }
+               result[key] = members
+       }
+
+       return result, nil
+}
+
+// verifyHLLs checks cardinality estimates and PFMERGE union
+func verifyHLLs(ctx context.Context, client *redis.Client, stored 
map[string][]string, r *SeededRandom) error {
+       counts := make([]int64, 2)
+       for i := 0; i < 2; i++ {
+               key := fmt.Sprintf("hll:%d", i)
+               memberCount := int64(1000 + r.Int(2000))
+               for j := 0; j < int(memberCount); j++ {
+                       _ = fmt.Sprintf("h%d-%s", i, r.String(24)) // consume 
the same random sequence
+               }
+
+               actual, err := client.PFCount(ctx, key).Result()
+               if err != nil {
+                       return fmt.Errorf("pfcount %s: %w", key, err)
+               }
+               if diff := float64(actual-memberCount) / float64(memberCount); 
diff < -hllCountTolerance || diff > hllCountTolerance {
+                       return fmt.Errorf("hll %s: count %d, expected ~%d", 
key, actual, memberCount)
+               }
+               counts[i] = memberCount
+       }
+
+       // Merge both keys and check the union cardinality (members are 
disjoint)
+       if err := client.PFMerge(ctx, "hll:merged", "hll:0", "hll:1").Err(); 
err != nil {
+               return fmt.Errorf("pfmerge: %w", err)
+       }
+       expectedUnion := counts[0] + counts[1]
+       union, err := client.PFCount(ctx, "hll:merged").Result()
+       if err != nil {
+               return fmt.Errorf("pfcount merged: %w", err)
+       }
+       if diff := float64(union-expectedUnion) / float64(expectedUnion); diff 
< -hllMergeTolerance || diff > hllMergeTolerance {
+               return fmt.Errorf("hll merged: count %d, expected ~%d", union, 
expectedUnion)
+       }
+       return nil
+}
+
+// populateTDigests creates t-digests with random values
+func populateTDigests(ctx context.Context, client *redis.Client, r 
*SeededRandom) (map[string][]float64, error) {
+       result := make(map[string][]float64)
+
+       for i := 0; i < 3; i++ {
+               key := fmt.Sprintf("td:%d", i)
+               if err := client.Do(ctx, "TDIGEST.CREATE", key).Err(); err != 
nil {
+                       return nil, fmt.Errorf("tdigest.create %s: %w", key, 
err)
+               }
+               valueCount := 100 + r.Int(300)
+               values := make([]float64, valueCount)
+               args := make([]interface{}, 0, valueCount+1)
+               args = append(args, key)
+               for j := 0; j < valueCount; j++ {
+                       values[j] = r.Float64() * 1000
+                       args = append(args, values[j])
+               }
+               if err := client.Do(ctx, append([]interface{}{"TDIGEST.ADD"}, 
args...)...).Err(); err != nil {
+                       return nil, fmt.Errorf("tdigest.add %s: %w", key, err)
+               }
+               result[key] = values
+       }
+
+       return result, nil
+}
+
+// tdigestInfoObservations extracts the exact value count from TDIGEST.INFO.
+// kvrocks replies with a RESP2 flat array of field/value pairs.
+func tdigestInfoObservations(ctx context.Context, client *redis.Client, key 
string) (int64, error) {
+       info, err := client.Do(ctx, "TDIGEST.INFO", key).Result()
+       if err != nil {
+               return 0, fmt.Errorf("tdigest.info %s: %w", key, err)
+       }
+       switch v := info.(type) {
+       case []interface{}:
+               for j := 0; j+1 < len(v); j += 2 {
+                       if strings.ToLower(fmt.Sprint(v[j])) == "observations" {
+                               return strconv.ParseInt(fmt.Sprint(v[j+1]), 10, 
64)
+                       }
+               }
+       case map[interface{}]interface{}:
+               for k, obs := range v {
+                       if strings.ToLower(fmt.Sprint(k)) == "observations" {
+                               return strconv.ParseInt(fmt.Sprint(obs), 10, 64)
+                       }
+               }
+       }
+       return 0, fmt.Errorf("tdigest.info %s: observations field not found", 
key)
+}
+
+// verifyTDigests checks exact value counts, quantile sanity, and TDIGEST.MERGE
+func verifyTDigests(ctx context.Context, client *redis.Client, stored 
map[string][]float64, r *SeededRandom) error {
+       counts := make([]int64, 3)
+       for i := 0; i < 3; i++ {
+               key := fmt.Sprintf("td:%d", i)
+               valueCount := 100 + r.Int(300)
+               values := make([]float64, valueCount)
+               for j := 0; j < valueCount; j++ {
+                       values[j] = r.Float64() * 1000
+               }
+
+               obs, err := tdigestInfoObservations(ctx, client, key)
+               if err != nil {
+                       return err
+               }
+               if obs != int64(valueCount) {
+                       return fmt.Errorf("tdigest %s: observations %d, 
expected %d", key, obs, valueCount)
+               }
+               counts[i] = int64(valueCount)
+
+               // Quantile estimates must stay within the data range
+               quants, err := client.Do(ctx, "TDIGEST.QUANTILE", key, 0, 0.5, 
1).Result()
+               if err != nil {
+                       return fmt.Errorf("tdigest.quantile %s: %w", key, err)
+               }
+               qs := make([]float64, 3)
+               for j, raw := range quants.([]interface{}) {
+                       qs[j], err = strconv.ParseFloat(fmt.Sprint(raw), 64)
+                       if err != nil {
+                               return fmt.Errorf("tdigest.quantile %s: bad 
value %v: %w", key, raw, err)
+                       }
+               }
+               lo, hi := values[0], values[0]
+               for _, v := range values {
+                       if v < lo {
+                               lo = v
+                       }
+                       if v > hi {
+                               hi = v
+                       }
+               }
+               span := hi - lo
+               if qs[0] < lo-0.15*span || qs[0] > lo+0.15*span || qs[2] < 
hi-0.15*span || qs[2] > hi+0.15*span {
+                       return fmt.Errorf("tdigest %s: quantiles %v outside 
expected range [%f, %f]", key, qs, lo, hi)
+               }
+               if qs[0] > qs[1] || qs[1] > qs[2] {
+                       return fmt.Errorf("tdigest %s: quantiles not monotonic: 
%v", key, qs)
+               }
+       }
+
+       // Merge two digests and check the total observation count
+       if err := client.Do(ctx, "TDIGEST.MERGE", "td:merged", 2, "td:0", 
"td:1").Err(); err != nil {
+               return fmt.Errorf("tdigest.merge: %w", err)
+       }
+       mergedObs, err := tdigestInfoObservations(ctx, client, "td:merged")
+       if err != nil {
+               return err
+       }
+       if mergedObs != counts[0]+counts[1] {
+               return fmt.Errorf("tdigest merged: observations %d, expected 
%d", mergedObs, counts[0]+counts[1])
+       }
+       return nil
+}
+
+// tsValueTolerance allows for float round-trip error in double values
+const tsValueTolerance = 1e-9
+
+// populateTimeSeries adds random samples to time series keys (TS.ADD
+// auto-creates the series, so no TS.CREATE is needed)
+func populateTimeSeries(ctx context.Context, client *redis.Client, r 
*SeededRandom) (map[string][]TSSample, error) {
+       result := make(map[string][]TSSample)
+
+       for i := 0; i < 3; i++ {
+               key := fmt.Sprintf("ts:%d", i)
+               sampleCount := 20 + r.Int(30)
+               base := uint64(1000000000 + i*1000000)
+               samples := make([]TSSample, sampleCount)
+
+               for j := 0; j < sampleCount; j++ {
+                       samples[j] = TSSample{Timestamp: base + uint64(j*1000), 
Value: r.Float64() * 1000}
+                       if err := client.Do(ctx, "TS.ADD", key, 
samples[j].Timestamp, samples[j].Value).Err(); err != nil {
+                               return nil, fmt.Errorf("ts.add %s: %w", key, 
err)
+                       }
+               }
+               result[key] = samples
+       }
+
+       return result, nil
+}
+
+// parseTSSample parses a kvrocks TS reply sample: an array of [timestamp, 
value]
+func parseTSSample(raw interface{}) (uint64, float64, error) {
+       s, ok := raw.([]interface{})
+       if !ok || len(s) != 2 {
+               return 0, 0, fmt.Errorf("unexpected TS sample reply: %v", raw)
+       }
+       ts, err := strconv.ParseUint(fmt.Sprint(s[0]), 10, 64)
+       if err != nil {
+               return 0, 0, fmt.Errorf("bad TS timestamp %v: %w", s[0], err)
+       }
+       val, err := strconv.ParseFloat(fmt.Sprint(s[1]), 64)
+       if err != nil {
+               return 0, 0, fmt.Errorf("bad TS value %v: %w", s[1], err)
+       }
+       return ts, val, nil
+}
+
+// verifyTimeSeries checks TS.GET (last sample) and TS.RANGE (all samples)
+func verifyTimeSeries(ctx context.Context, client *redis.Client, stored 
map[string][]TSSample, r *SeededRandom) error {
+       for i := 0; i < 3; i++ {
+               key := fmt.Sprintf("ts:%d", i)
+               sampleCount := 20 + r.Int(30)
+               base := uint64(1000000000 + i*1000000)
+               expected := make([]TSSample, sampleCount)
+               for j := 0; j < sampleCount; j++ {
+                       expected[j] = TSSample{Timestamp: base + 
uint64(j*1000), Value: r.Float64() * 1000}
+               }
+
+               // TS.GET returns the last sample
+               got, err := client.Do(ctx, "TS.GET", key).Result()
+               if err != nil {
+                       return fmt.Errorf("ts.get %s: %w", key, err)
+               }
+               lastTs, lastVal, err := parseTSSample(got.([]interface{})[0])
+               if err != nil {
+                       return fmt.Errorf("ts.get %s: %w", key, err)
+               }
+               last := TSSample{Timestamp: lastTs, Value: lastVal}
+               expLast := expected[sampleCount-1]
+               if last.Timestamp != expLast.Timestamp || 
math.Abs(last.Value-expLast.Value) > tsValueTolerance {
+                       return fmt.Errorf("ts %s: last sample (%d, %f), 
expected (%d, %f)",
+                               key, last.Timestamp, last.Value, 
expLast.Timestamp, expLast.Value)
+               }
+
+               // TS.RANGE returns all samples in order
+               rangeReply, err := client.Do(ctx, "TS.RANGE", key, 0, 
uint64(1)<<62).Result()
+               if err != nil {
+                       return fmt.Errorf("ts.range %s: %w", key, err)
+               }
+               samples, ok := rangeReply.([]interface{})
+               if !ok || len(samples) != sampleCount {
+                       return fmt.Errorf("ts %s: range returned %v samples, 
expected %d", key, len(samples), sampleCount)
+               }
+               for j := 0; j < sampleCount; j++ {
+                       ts, val, err := parseTSSample(samples[j])
+                       if err != nil {
+                               return fmt.Errorf("ts.range %s[%d]: %w", key, 
j, err)
+                       }
+                       if ts != expected[j].Timestamp || 
math.Abs(val-expected[j].Value) > tsValueTolerance {
+                               return fmt.Errorf("ts %s[%d]: (%d, %f), 
expected (%d, %f)",
+                                       key, j, ts, val, expected[j].Timestamp, 
expected[j].Value)
+                       }
+               }
+       }
+       return nil
+}

Reply via email to