Files
seaweedfs/weed/filer/redis2/universal_redis_store_test.go
T
03f773f087 redis2: pipeline entry reads when listing a directory (#12042)
ListDirectoryEntries fetched every member with its own GET, so a page of
1024 entries cost 1024 round trips. Fetch the values in pipelined batches
of 128 as the loop reaches them and walk the replies in order. Each reply
keeps its own error, so missing and expired members are cleaned up and
skipped exactly as before, and an unreadable member still ends the
listing at that name. Batching keeps a caller that lists with a huge
limit and stops after a few entries from reading the whole directory.

Co-authored-by: Patrick Schmidt <1310072+Woellchen@users.noreply.github.com>
Co-authored-by: user.email <chrislusf@users.noreply.github.com>
2026-10-24 15:04:37 -07:00

421 lines
14 KiB
Go

package redis2
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/redis/go-redis/v9"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func newTestStore(t *testing.T, keyPrefix string) (*UniversalRedis2Store, util.FullPath) {
t.Helper()
if os.Getenv("RUN_REDIS_TESTS") != "1" {
t.Skip("redis2 tests are disabled. Start a redis-server and set RUN_REDIS_TESTS=1 to enable, REDIS_ADDR defaults to 127.0.0.1:6379.")
}
addr := os.Getenv("REDIS_ADDR")
if addr == "" {
addr = "127.0.0.1:6379"
}
ctx := context.Background()
client := redis.NewClient(&redis.Options{Addr: addr})
t.Cleanup(func() {
if err := client.Close(); err != nil {
t.Errorf("close redis client: %v", err)
}
})
if err := client.Ping(ctx).Err(); err != nil {
t.Fatalf("connect to redis at %s: %v", addr, err)
}
store := &UniversalRedis2Store{Client: client, keyPrefix: keyPrefix}
dir := util.FullPath(fmt.Sprintf("/redis2_test_%d", time.Now().UnixNano()))
t.Cleanup(func() {
if err := store.DeleteFolderChildren(ctx, dir); err != nil {
t.Errorf("cleanup %s children: %v", dir, err)
}
if err := store.DeleteEntry(ctx, dir); err != nil {
t.Errorf("cleanup %s: %v", dir, err)
}
})
return store, dir
}
func insertTestEntry(t *testing.T, store *UniversalRedis2Store, path util.FullPath, ttlSec int32) {
t.Helper()
now := time.Now()
if err := store.InsertEntry(context.Background(), &filer.Entry{
FullPath: path,
Attr: filer.Attr{Crtime: now, Mtime: now, Mode: 0644, TtlSec: ttlSec},
}); err != nil {
t.Fatalf("InsertEntry %s: %v", path, err)
}
}
func listNames(t *testing.T, store *UniversalRedis2Store, dir util.FullPath) []string {
t.Helper()
names := []string{}
if _, err := store.ListDirectoryEntries(context.Background(), dir, "", true, 100, func(entry *filer.Entry) (bool, error) {
_, name := entry.DirAndName()
names = append(names, name)
return true, nil
}); err != nil {
t.Fatalf("ListDirectoryEntries %s: %v", dir, err)
}
return names
}
func indexMembers(t *testing.T, store *UniversalRedis2Store, dir util.FullPath) []string {
t.Helper()
members, err := store.Client.ZRangeByLex(context.Background(), store.getKey(genDirectoryListKey(string(dir))), &redis.ZRangeBy{Min: "-", Max: "+"}).Result()
if err != nil {
t.Fatalf("read directory index of %s: %v", dir, err)
}
return members
}
func TestListDirectoryEntriesRemovesOrphanedIndexMembers(t *testing.T) {
for _, keyPrefix := range []string{"", "sw:"} {
t.Run("keyPrefix="+keyPrefix, func(t *testing.T) {
store, dir := newTestStore(t, keyPrefix)
insertTestEntry(t, store, dir.Child("alive"), 0)
insertTestEntry(t, store, dir.Child("orphan"), 0)
if err := store.Client.Del(context.Background(), store.getKey(string(dir.Child("orphan")))).Err(); err != nil {
t.Fatalf("drop value key: %v", err)
}
if names := listNames(t, store, dir); len(names) != 1 || names[0] != "alive" {
t.Fatalf("listed %v, want [alive]", names)
}
if members := indexMembers(t, store, dir); len(members) != 1 || members[0] != "alive" {
t.Fatalf("directory index holds %v, want [alive]", members)
}
})
}
}
func TestRemoveOrphanedDirectoryListMemberKeepsRecreatedEntry(t *testing.T) {
for _, keyPrefix := range []string{"", "sw:"} {
t.Run("keyPrefix="+keyPrefix, func(t *testing.T) {
store, dir := newTestStore(t, keyPrefix)
path := dir.Child("recreated")
insertTestEntry(t, store, path, 0)
store.removeOrphanedDirectoryListMember(context.Background(), dir, "recreated")
if members := indexMembers(t, store, dir); len(members) != 1 || members[0] != "recreated" {
t.Fatalf("directory index holds %v, want [recreated]", members)
}
if names := listNames(t, store, dir); len(names) != 1 || names[0] != "recreated" {
t.Fatalf("listed %v, want [recreated]", names)
}
})
}
}
func TestRemoveOrphanedDirectoryListMemberKeepsDirectoryWithChildren(t *testing.T) {
for _, keyPrefix := range []string{"", "sw:"} {
t.Run("keyPrefix="+keyPrefix, func(t *testing.T) {
store, dir := newTestStore(t, keyPrefix)
sub := dir.Child("sub")
insertTestEntry(t, store, sub, 0)
insertTestEntry(t, store, sub.Child("kid"), 0)
defer func() {
if err := store.DeleteFolderChildren(context.Background(), sub); err != nil {
t.Errorf("cleanup %s children: %v", sub, err)
}
}()
// evict the directory's own value while its child index is live
if err := store.Client.Del(context.Background(), store.getKey(string(sub))).Err(); err != nil {
t.Fatalf("drop value key: %v", err)
}
if names := listNames(t, store, dir); len(names) != 0 {
t.Fatalf("listed %v, want none", names)
}
if members := indexMembers(t, store, dir); len(members) != 1 || members[0] != "sub" {
t.Fatalf("directory index holds %v, want [sub]", members)
}
if exists, err := store.Client.Exists(context.Background(), store.getKey(string(sub.Child("kid")))).Result(); err != nil || exists != 1 {
t.Fatalf("child value key exists=%d err=%v, want it kept", exists, err)
}
})
}
}
func TestRemoveOrphanedDirectoryListMemberSkipsSuperLargeDirectory(t *testing.T) {
store, dir := newTestStore(t, "")
store.loadSuperLargeDirectories([]string{string(dir)})
// a member left from before the directory became super large
if err := store.Client.ZAdd(context.Background(), store.getKey(genDirectoryListKey(string(dir))), redis.Z{Score: 0, Member: "legacy"}).Err(); err != nil {
t.Fatalf("plant legacy member: %v", err)
}
store.removeOrphanedDirectoryListMember(context.Background(), dir, "legacy")
if members := indexMembers(t, store, dir); len(members) != 1 || members[0] != "legacy" {
t.Fatalf("directory index holds %v, want [legacy] untouched", members)
}
}
func requireValueKey(t *testing.T, store *UniversalRedis2Store, path util.FullPath) {
t.Helper()
if exists, err := store.Client.Exists(context.Background(), store.getKey(string(path))).Result(); err != nil || exists != 1 {
t.Fatalf("value key %s exists=%d err=%v, want it present", path, exists, err)
}
}
func TestListDirectoryEntriesRemovesIndexMembersExpiredByRedis(t *testing.T) {
store, dir := newTestStore(t, "")
insertTestEntry(t, store, dir.Child("ttl"), 1)
requireValueKey(t, store, dir.Child("ttl"))
deadline := time.Now().Add(5 * time.Second)
for {
exists, err := store.Client.Exists(context.Background(), store.getKey(string(dir.Child("ttl")))).Result()
if err != nil {
t.Fatalf("check value key: %v", err)
}
if exists == 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("redis did not expire the value key, the logical expiry path is not being bypassed")
}
time.Sleep(50 * time.Millisecond)
}
if names := listNames(t, store, dir); len(names) != 0 {
t.Fatalf("listed %v, want none", names)
}
if members := indexMembers(t, store, dir); len(members) != 0 {
t.Fatalf("directory index holds %v, want none", members)
}
}
// logically expired (Crtime + TtlSec long past) while the physical key survives,
// because the redis TTL re-arms from the SET time
func insertLogicallyExpiredTestEntry(t *testing.T, store *UniversalRedis2Store, path util.FullPath) {
t.Helper()
created := time.Now().Add(-time.Hour)
if err := store.InsertEntry(context.Background(), &filer.Entry{
FullPath: path,
Attr: filer.Attr{Crtime: created, Mtime: created, Mode: 0644, TtlSec: 60},
}); err != nil {
t.Fatalf("InsertEntry %s: %v", path, err)
}
}
func TestListDirectoryEntriesDeletesLogicallyExpiredEntries(t *testing.T) {
for _, keyPrefix := range []string{"", "sw:"} {
t.Run("keyPrefix="+keyPrefix, func(t *testing.T) {
store, dir := newTestStore(t, keyPrefix)
insertLogicallyExpiredTestEntry(t, store, dir.Child("stale"))
requireValueKey(t, store, dir.Child("stale"))
if names := listNames(t, store, dir); len(names) != 0 {
t.Fatalf("listed %v, want none", names)
}
if exists, err := store.Client.Exists(context.Background(), store.getKey(string(dir.Child("stale")))).Result(); err != nil || exists != 0 {
t.Fatalf("value key exists=%d err=%v, want it deleted", exists, err)
}
if members := indexMembers(t, store, dir); len(members) != 0 {
t.Fatalf("directory index holds %v, want none", members)
}
})
}
}
func TestListDirectoryEntriesSkipsMissingAndExpiredMembersInOrder(t *testing.T) {
for _, keyPrefix := range []string{"", "sw:"} {
t.Run("keyPrefix="+keyPrefix, func(t *testing.T) {
store, dir := newTestStore(t, keyPrefix)
insertTestEntry(t, store, dir.Child("a"), 0)
insertTestEntry(t, store, dir.Child("b"), 0)
insertLogicallyExpiredTestEntry(t, store, dir.Child("c"))
insertTestEntry(t, store, dir.Child("d"), 0)
if err := store.Client.Del(context.Background(), store.getKey(string(dir.Child("b")))).Err(); err != nil {
t.Fatalf("drop value key: %v", err)
}
if names := listNames(t, store, dir); fmt.Sprint(names) != "[a d]" {
t.Fatalf("listed %v, want [a d]", names)
}
if members := indexMembers(t, store, dir); fmt.Sprint(members) != "[a d]" {
t.Fatalf("directory index holds %v, want [a d]", members)
}
})
}
}
func TestListDirectoryEntriesAcrossFetchBatches(t *testing.T) {
store, dir := newTestStore(t, "")
var want []string
for i := 0; i < listFetchBatchSize+2; i++ {
name := fmt.Sprintf("f%04d", i)
insertTestEntry(t, store, dir.Child(name), 0)
if i != listFetchBatchSize {
want = append(want, name)
}
}
// the first member of the second batch
missing := dir.Child(fmt.Sprintf("f%04d", listFetchBatchSize))
if err := store.Client.Del(context.Background(), store.getKey(string(missing))).Err(); err != nil {
t.Fatalf("drop value key: %v", err)
}
var names []string
if _, err := store.ListDirectoryEntries(context.Background(), dir, "", true, 1000, func(entry *filer.Entry) (bool, error) {
names = append(names, entry.Name())
return true, nil
}); err != nil {
t.Fatalf("ListDirectoryEntries %s: %v", dir, err)
}
if fmt.Sprint(names) != fmt.Sprint(want) {
t.Fatalf("listed %v, want %v", names, want)
}
if members := indexMembers(t, store, dir); fmt.Sprint(members) != fmt.Sprint(want) {
t.Fatalf("directory index holds %v, want %v", members, want)
}
}
func TestListDirectoryEntriesStopsAtUnreadableMember(t *testing.T) {
store, dir := newTestStore(t, "")
ctx := context.Background()
insertTestEntry(t, store, dir.Child("a"), 0)
insertTestEntry(t, store, dir.Child("b"), 0)
insertTestEntry(t, store, dir.Child("c"), 0)
// a value of the wrong type makes its GET fail with something other than redis.Nil
key := store.getKey(string(dir.Child("b")))
if err := store.Client.Del(ctx, key).Err(); err != nil {
t.Fatalf("drop value key: %v", err)
}
if err := store.Client.HSet(ctx, key, "f", "v").Err(); err != nil {
t.Fatalf("plant hash: %v", err)
}
var names []string
lastFileName, err := store.ListDirectoryEntries(ctx, dir, "", true, 100, func(entry *filer.Entry) (bool, error) {
names = append(names, entry.Name())
return true, nil
})
if err == nil {
t.Fatal("ListDirectoryEntries returned no error for an unreadable member")
}
if fmt.Sprint(names) != "[a]" || lastFileName != "b" {
t.Fatalf("listed %v up to %q, want [a] up to \"b\"", names, lastFileName)
}
}
func TestDeleteExpiredEntryKeepsRecreatedValue(t *testing.T) {
store, dir := newTestStore(t, "")
path := dir.Child("phoenix")
insertLogicallyExpiredTestEntry(t, store, path)
// a recreate lands after the lister decided to expire the old value
insertTestEntry(t, store, path, 0)
store.deleteExpiredEntry(context.Background(), dir, path, "phoenix")
if names := listNames(t, store, dir); len(names) != 1 || names[0] != "phoenix" {
t.Fatalf("listed %v, want [phoenix]", names)
}
if members := indexMembers(t, store, dir); len(members) != 1 || members[0] != "phoenix" {
t.Fatalf("directory index holds %v, want [phoenix]", members)
}
}
func TestDeleteIfUnchangedScriptOnlyDeletesSameBytes(t *testing.T) {
store, dir := newTestStore(t, "")
key := store.getKey(string(dir.Child("guarded")))
if err := store.Client.Set(context.Background(), key, "v1", 0).Err(); err != nil {
t.Fatalf("set: %v", err)
}
if deleted, err := deleteIfUnchangedScript.Run(context.Background(), store.Client, []string{key}, []byte("v2")).Int(); err != nil || deleted != 0 {
t.Fatalf("deleted=%d err=%v, want no delete on changed bytes", deleted, err)
}
if deleted, err := deleteIfUnchangedScript.Run(context.Background(), store.Client, []string{key}, []byte("v1")).Int(); err != nil || deleted != 1 {
t.Fatalf("deleted=%d err=%v, want delete on matching bytes", deleted, err)
}
if exists, err := store.Client.Exists(context.Background(), key).Result(); err != nil || exists != 0 {
t.Fatalf("exists=%d err=%v, want key gone", exists, err)
}
if deleted, err := deleteIfUnchangedScript.Run(context.Background(), store.Client, []string{key}, []byte("v1")).Int(); err != nil || deleted != -1 {
t.Fatalf("deleted=%d err=%v, want -1 on a missing key", deleted, err)
}
}
func TestDeleteEntryKeepsChildListing(t *testing.T) {
store, dir := newTestStore(t, "")
ctx := context.Background()
insertTestEntry(t, store, dir.Child("obj"), 0)
// The listing is the only record that the child sits under this directory, so
// removing the directory entry must leave it alone. An entry that arrived after
// the caller judged the directory empty would otherwise become unreachable.
if err := store.DeleteEntry(ctx, dir); err != nil {
t.Fatalf("DeleteEntry %s: %v", dir, err)
}
if names := listNames(t, store, dir); len(names) != 1 || names[0] != "obj" {
t.Errorf("child should still be listed after the directory entry is deleted, got %v", names)
}
// DeleteFolderChildren owns that cleanup, and takes the listing with it
if err := store.DeleteFolderChildren(ctx, dir); err != nil {
t.Fatalf("DeleteFolderChildren %s: %v", dir, err)
}
if names := listNames(t, store, dir); len(names) != 0 {
t.Errorf("listing should be empty once the children are deleted, got %v", names)
}
if n, err := store.Client.Exists(ctx, store.getKey(genDirectoryListKey(string(dir)))).Result(); err != nil {
t.Fatalf("exists %s: %v", dir, err)
} else if n != 0 {
t.Errorf("listing key should be removed with the children, still present")
}
}