1
0
mirror of https://github.com/redis/go-redis.git synced 2026-10-24 07:59:20 +03:00
Files
go-redis/bless_commands_test.go
Vladislav Kotsev e4a198a8c7 feat(cmd): bless (#4041)
* feat: add bless commands

* fix: use int64 for count

* fix: keylessnes, docs, tests
2026-10-07 12:33:09 +03:00

405 lines
14 KiB
Go

package redis_test
import (
"context"
"fmt"
"regexp"
"strconv"
"sync"
. "github.com/bsm/ginkgo/v2"
. "github.com/bsm/gomega"
"github.com/redis/go-redis/v9"
)
// blessedKeysRe matches the top-level "blessed_keys:N" field in INFO output.
var blessedKeysRe = regexp.MustCompile(`(?m)^blessed_keys:(\d+)\r?$`)
// blessScanAll drains BLESS SCAN on a single node and returns every key it
// reports. The BLESS SCAN cursor is node-local, so this is the building block
// for scanning a ClusterClient or Ring master by master.
func blessScanAll(ctx context.Context, node *redis.Client, flag redis.BlessFlag) ([]string, error) {
var keys []string
var cursor uint64
for {
page, next, err := node.BlessScan(ctx, cursor, flag, 5).Result()
if err != nil {
return nil, err
}
keys = append(keys, page...)
if next == 0 {
return keys, nil
}
cursor = next
}
}
// blessScanEachNode runs blessScanAll on every node visited by forEach
// (ClusterClient.ForEachMaster or Ring.ForEachShard) and merges the results.
// forEach runs its callback concurrently, hence the mutex.
func blessScanEachNode(
ctx context.Context,
flag redis.BlessFlag,
forEach func(context.Context, func(context.Context, *redis.Client) error) error,
) ([]string, error) {
var mu sync.Mutex
var all []string
err := forEach(ctx, func(ctx context.Context, node *redis.Client) error {
keys, err := blessScanAll(ctx, node, flag)
if err != nil {
return err
}
mu.Lock()
all = append(all, keys...)
mu.Unlock()
return nil
})
return all, err
}
var _ = Describe("BLESS commands", func() {
ctx := context.TODO()
var client *redis.Client
const unknownFlag = redis.BlessFlag("NO-SUCH-FLAG")
// blessedKeys returns the blessed_keys counter from INFO, failing the spec
// if the server does not report one.
blessedKeys := func() int {
GinkgoHelper()
info, err := client.Info(ctx, "everything").Result()
Expect(err).NotTo(HaveOccurred())
m := blessedKeysRe.FindStringSubmatch(info)
Expect(m).NotTo(BeNil(), "INFO does not report blessed_keys")
n, err := strconv.Atoi(m[1])
Expect(err).NotTo(HaveOccurred())
return n
}
BeforeEach(func() {
SkipBeforeRedisVersion("8.12", "BLESS requires Redis 8.12")
client = redis.NewClient(redisOptions())
Expect(client.FlushDB(ctx).Err()).NotTo(HaveOccurred())
})
AfterEach(func() {
Expect(client.Close()).NotTo(HaveOccurred())
})
Describe("BLESS SET", func() {
It("returns 1 when the flag is newly set and 0 when it was already set", func() {
Expect(client.Set(ctx, "key1", "v", 0).Err()).NotTo(HaveOccurred())
Expect(client.BlessSet(ctx, "key1", redis.BlessNoEvict).Result()).To(Equal(int64(1)))
Expect(client.BlessSet(ctx, "key1", redis.BlessNoEvict).Result()).To(Equal(int64(0)))
flags, err := client.BlessGet(ctx, "key1").Result()
Expect(err).NotTo(HaveOccurred())
Expect(flags).To(Equal([]string{string(redis.BlessNoEvict)}))
})
It("rejects an unknown flag", func() {
Expect(client.Set(ctx, "key1", "v", 0).Err()).NotTo(HaveOccurred())
err := client.BlessSet(ctx, "key1", unknownFlag).Err()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(HavePrefix("ERR"))
// A rejected flag must not leave any state behind.
flags, err := client.BlessGet(ctx, "key1").Result()
Expect(err).NotTo(HaveOccurred())
Expect(flags).To(BeEmpty())
})
})
Describe("BLESS GET", func() {
It("reports no flags on a key that never had one set", func() {
Expect(client.Set(ctx, "key1", "v", 0).Err()).NotTo(HaveOccurred())
flags, err := client.BlessGet(ctx, "key1").Result()
Expect(err).NotTo(HaveOccurred())
Expect(flags).To(BeEmpty())
})
})
Describe("BLESS CLEAR", func() {
It("returns 1 when the flag was present and 0 when it was not", func() {
Expect(client.Set(ctx, "key1", "v", 0).Err()).NotTo(HaveOccurred())
Expect(client.BlessSet(ctx, "key1", redis.BlessNoEvict).Result()).To(Equal(int64(1)))
Expect(client.BlessClear(ctx, "key1", redis.BlessNoEvict).Result()).To(Equal(int64(1)))
Expect(client.BlessClear(ctx, "key1", redis.BlessNoEvict).Result()).To(Equal(int64(0)))
flags, err := client.BlessGet(ctx, "key1").Result()
Expect(err).NotTo(HaveOccurred())
Expect(flags).To(BeEmpty())
})
It("returns 0 for a key that was never blessed", func() {
Expect(client.Set(ctx, "key1", "v", 0).Err()).NotTo(HaveOccurred())
Expect(client.BlessClear(ctx, "key1", redis.BlessNoEvict).Result()).To(Equal(int64(0)))
})
It("rejects an unknown flag", func() {
Expect(client.Set(ctx, "key1", "v", 0).Err()).NotTo(HaveOccurred())
Expect(client.BlessSet(ctx, "key1", redis.BlessNoEvict).Err()).NotTo(HaveOccurred())
err := client.BlessClear(ctx, "key1", unknownFlag).Err()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(HavePrefix("ERR"))
// The existing flag must survive a rejected CLEAR.
flags, err := client.BlessGet(ctx, "key1").Result()
Expect(err).NotTo(HaveOccurred())
Expect(flags).To(Equal([]string{string(redis.BlessNoEvict)}))
})
})
Describe("BLESS SCAN", func() {
It("scans keys carrying a flag across multiple pages", func() {
want := make([]string, 0, 20)
for i := range 20 {
key := fmt.Sprintf("scan:%02d", i)
want = append(want, key)
Expect(client.Set(ctx, key, "v", 0).Err()).NotTo(HaveOccurred())
Expect(client.BlessSet(ctx, key, redis.BlessNoEvict).Result()).To(Equal(int64(1)))
}
// An unblessed key must not show up in the scan.
Expect(client.Set(ctx, "plain", "v", 0).Err()).NotTo(HaveOccurred())
var keys []string
cursor := uint64(0)
for {
page, next, err := client.BlessScan(ctx, cursor, redis.BlessNoEvict, 5).Result()
Expect(err).NotTo(HaveOccurred())
keys = append(keys, page...)
cursor = next
if cursor == 0 {
break
}
}
Expect(keys).To(ConsistOf(want))
})
It("returns an empty result when no key carries the flag", func() {
Expect(client.Set(ctx, "key1", "v", 0).Err()).NotTo(HaveOccurred())
keys, cursor, err := client.BlessScan(ctx, 0, redis.BlessNoEvict, 0).Result()
Expect(err).NotTo(HaveOccurred())
Expect(keys).To(BeEmpty())
Expect(cursor).To(Equal(uint64(0)))
})
It("rejects an unknown flag", func() {
err := client.BlessScan(ctx, 0, unknownFlag, 0).Err()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(HavePrefix("ERR"))
})
})
Describe("INFO blessed_keys", func() {
It("tracks the number of blessed keys through SET, CLEAR and DEL", func() {
Expect(blessedKeys()).To(Equal(0))
Expect(client.Set(ctx, "key1", "v", 0).Err()).NotTo(HaveOccurred())
Expect(client.Set(ctx, "key2", "v", 0).Err()).NotTo(HaveOccurred())
Expect(client.BlessSet(ctx, "key1", redis.BlessNoEvict).Result()).To(Equal(int64(1)))
Expect(blessedKeys()).To(Equal(1))
Expect(client.BlessSet(ctx, "key2", redis.BlessNoEvict).Result()).To(Equal(int64(1)))
Expect(blessedKeys()).To(Equal(2))
// Re-setting an existing flag must not double count.
Expect(client.BlessSet(ctx, "key2", redis.BlessNoEvict).Result()).To(Equal(int64(0)))
Expect(blessedKeys()).To(Equal(2))
Expect(client.BlessClear(ctx, "key1", redis.BlessNoEvict).Result()).To(Equal(int64(1)))
Expect(blessedKeys()).To(Equal(1))
// Deleting a blessed key releases its slot in the counter.
Expect(client.Del(ctx, "key2").Result()).To(Equal(int64(1)))
Expect(blessedKeys()).To(Equal(0))
})
It("resets to zero on FLUSHDB", func() {
Expect(client.Set(ctx, "key1", "v", 0).Err()).NotTo(HaveOccurred())
Expect(client.BlessSet(ctx, "key1", redis.BlessNoEvict).Result()).To(Equal(int64(1)))
Expect(blessedKeys()).To(Equal(1))
Expect(client.FlushDB(ctx).Err()).NotTo(HaveOccurred())
Expect(blessedKeys()).To(Equal(0))
})
})
})
// BLESS SET/GET/CLEAR carry the key at position 2, so a ClusterClient routes
// them to the owning master like any other keyed command. BLESS SCAN is
// keyless and its cursor is node-local, so it has to be issued to each master
// individually; these specs pin both halves of that contract.
var _ = Describe("BLESS commands on ClusterClient", func() {
ctx := context.TODO()
var client *redis.ClusterClient
// Enough keys to land on every master of the three-shard test cluster
// with overwhelming probability.
const numKeys = 60
BeforeEach(func() {
if RECluster {
Skip("OSS cluster scenario is not configured under RE_CLUSTER")
}
SkipBeforeRedisVersion("8.12", "BLESS requires Redis 8.12")
client = cluster.newClusterClient(ctx, redisClusterOptions())
Expect(client.ForEachMaster(ctx, func(ctx context.Context, master *redis.Client) error {
return master.FlushDB(ctx).Err()
})).NotTo(HaveOccurred())
})
AfterEach(func() {
_ = client.ForEachMaster(ctx, func(ctx context.Context, master *redis.Client) error {
return master.FlushDB(ctx).Err()
})
Expect(client.Close()).NotTo(HaveOccurred())
})
It("routes SET, GET and CLEAR to the master owning the key", func() {
for i := range numKeys {
key := fmt.Sprintf("cluster:%02d", i)
Expect(client.Set(ctx, key, "v", 0).Err()).NotTo(HaveOccurred())
Expect(client.BlessSet(ctx, key, redis.BlessNoEvict).Result()).To(Equal(int64(1)))
Expect(client.BlessGet(ctx, key).Result()).To(Equal([]string{string(redis.BlessNoEvict)}))
}
for i := range numKeys {
key := fmt.Sprintf("cluster:%02d", i)
Expect(client.BlessClear(ctx, key, redis.BlessNoEvict).Result()).To(Equal(int64(1)))
Expect(client.BlessGet(ctx, key).Result()).To(BeEmpty())
}
})
It("finds every blessed key only when each master is scanned separately", func() {
want := make([]string, 0, numKeys)
for i := range numKeys {
key := fmt.Sprintf("cluster:%02d", i)
want = append(want, key)
Expect(client.Set(ctx, key, "v", 0).Err()).NotTo(HaveOccurred())
Expect(client.BlessSet(ctx, key, redis.BlessNoEvict).Result()).To(Equal(int64(1)))
}
// Each master only reports its own slice of the keyspace.
var mu sync.Mutex
perMaster := map[string][]string{}
Expect(client.ForEachMaster(ctx, func(ctx context.Context, master *redis.Client) error {
keys, err := blessScanAll(ctx, master, redis.BlessNoEvict)
if err != nil {
return err
}
mu.Lock()
perMaster[master.Options().Addr] = keys
mu.Unlock()
return nil
})).NotTo(HaveOccurred())
Expect(perMaster).To(HaveLen(len(cluster.masters())))
for addr, keys := range perMaster {
Expect(keys).NotTo(BeEmpty(), "master %s reported no blessed keys", addr)
Expect(len(keys)).To(BeNumerically("<", numKeys), "master %s reported every key", addr)
}
// Merging the per-master scans yields the complete, duplicate-free set.
all, err := blessScanEachNode(ctx, redis.BlessNoEvict, client.ForEachMaster)
Expect(err).NotTo(HaveOccurred())
Expect(all).To(ConsistOf(want))
// A single BLESS SCAN through the ClusterClient lands on one arbitrary
// master and therefore cannot see the whole keyspace, which is why the
// per-master loop above is the supported pattern.
page, _, err := client.BlessScan(ctx, 0, redis.BlessNoEvict, numKeys*2).Result()
Expect(err).NotTo(HaveOccurred())
Expect(len(page)).To(BeNumerically("<", numKeys))
})
})
var _ = Describe("BLESS commands on Ring", func() {
ctx := context.TODO()
var ring *redis.Ring
// Enough keys to land on both ring shards with overwhelming probability.
const numKeys = 40
BeforeEach(func() {
if RECluster {
Skip("ring shards are not configured under RE_CLUSTER")
}
SkipBeforeRedisVersion("8.12", "BLESS requires Redis 8.12")
ring = redis.NewRing(redisRingOptions())
Expect(ring.ForEachShard(ctx, func(ctx context.Context, shard *redis.Client) error {
return shard.FlushDB(ctx).Err()
})).NotTo(HaveOccurred())
})
AfterEach(func() {
_ = ring.ForEachShard(ctx, func(ctx context.Context, shard *redis.Client) error {
return shard.FlushDB(ctx).Err()
})
Expect(ring.Close()).NotTo(HaveOccurred())
})
It("routes SET, GET and CLEAR to the shard owning the key", func() {
for i := range numKeys {
key := fmt.Sprintf("ring:%02d", i)
Expect(ring.Set(ctx, key, "v", 0).Err()).NotTo(HaveOccurred())
Expect(ring.BlessSet(ctx, key, redis.BlessNoEvict).Result()).To(Equal(int64(1)))
Expect(ring.BlessGet(ctx, key).Result()).To(Equal([]string{string(redis.BlessNoEvict)}))
}
for i := range numKeys {
key := fmt.Sprintf("ring:%02d", i)
Expect(ring.BlessClear(ctx, key, redis.BlessNoEvict).Result()).To(Equal(int64(1)))
Expect(ring.BlessGet(ctx, key).Result()).To(BeEmpty())
}
})
It("finds every blessed key only when each shard is scanned separately", func() {
want := make([]string, 0, numKeys)
for i := range numKeys {
key := fmt.Sprintf("ring:%02d", i)
want = append(want, key)
Expect(ring.Set(ctx, key, "v", 0).Err()).NotTo(HaveOccurred())
Expect(ring.BlessSet(ctx, key, redis.BlessNoEvict).Result()).To(Equal(int64(1)))
}
// Each shard only reports the keys hashed to it.
var mu sync.Mutex
perShard := map[string][]string{}
Expect(ring.ForEachShard(ctx, func(ctx context.Context, shard *redis.Client) error {
keys, err := blessScanAll(ctx, shard, redis.BlessNoEvict)
if err != nil {
return err
}
mu.Lock()
perShard[shard.Options().Addr] = keys
mu.Unlock()
return nil
})).NotTo(HaveOccurred())
Expect(perShard).To(HaveLen(ring.Len()))
for addr, keys := range perShard {
Expect(keys).NotTo(BeEmpty(), "shard %s reported no blessed keys", addr)
Expect(len(keys)).To(BeNumerically("<", numKeys), "shard %s reported every key", addr)
}
// Merging the per-shard scans yields the complete, duplicate-free set.
all, err := blessScanEachNode(ctx, redis.BlessNoEvict, ring.ForEachShard)
Expect(err).NotTo(HaveOccurred())
Expect(all).To(ConsistOf(want))
// A single BLESS SCAN through the Ring goes to a random shard and
// cannot see the whole keyspace.
page, _, err := ring.BlessScan(ctx, 0, redis.BlessNoEvict, numKeys*2).Result()
Expect(err).NotTo(HaveOccurred())
Expect(len(page)).To(BeNumerically("<", numKeys))
})
})