mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
* filer: restore a folder that received an entry while it was deleted The empty-folder cleaner checks that a folder is empty and then deletes it, and those two steps are not atomic. An entry created in between survives the delete but loses the directory holding it: still readable by its own path, yet absent from every listing until a later write happens to recreate the parent. Record the folders deleted in each pass and re-check them on the next one, putting back any that turned out to hold entries. The check waits a pass on purpose - a writer looks up the parent before inserting the child, so checking straight after the delete can still run ahead of the insert and see nothing. Restoring a directory that holds entries is always correct, and restoring one whose entry went away again just leaves an empty folder for a later pass to collect, so the repair needs no locking or coordination. Claude-Session: https://claude.ai/code/session_01HdLXMUopwgofPb1ZEmiE6r * filer: keep failed restores queued and inherit the ancestor's ownership Two gaps in the restore pass. A folder whose count or restore hit a transient store error was dropped from the tracking list and never looked at again, leaving its entries out of listings until some later write recreated the folder - the very thing the pass exists to avoid. Put those back for the next pass, still under the cap. A restored folder was minted with a fixed mode and no owner, so a directory that had been private came back world-readable and owned by root. Take the mode and ownership from the nearest ancestor still present instead. Claude-Session: https://claude.ai/code/session_01HdLXMUopwgofPb1ZEmiE6r * filer: let the redis stores keep a directory listing that still has entries On the redis stores the listing is not derived from the entries, it is the only record that they sit under that directory. DeleteEntry opened by dropping it outright, so an entry that arrived after the caller judged the directory empty lost its membership and became unreachable: readable by exact path, absent from every listing, and invisible to any later check, since counting the directory reads the listing that was just destroyed. Nothing could detect or repair it. Drop the listing in DeleteFolderChildren instead, alongside the children it describes, and leave it alone in DeleteEntry. redis3 needs it explicitly, since removeChildren clears the skip list nodes but not the list itself, and the plain redis store was leaking the key entirely. Claude-Session: https://claude.ai/code/session_01HdLXMUopwgofPb1ZEmiE6r * filer: restore folders with their own attributes, and observe them for a window Five gaps in the restore pass. The restored directory was reconstructed from whatever ancestor happened to still be present, and the mode was ORed with 0111 on the way. A private directory under a world-traversable parent came back granting traversal it had denied. Read the folder's own attributes before deleting it and put exactly those back. That also removes the ancestor walk, which treated a transient store error as "not found" and silently fell through to a broader ancestor. A single check a pass later was not a delay at all. Ticker sends coalesce, so when a pass runs long the next one starts immediately, and a writer already past its parent lookup can insert after the check has read zero - after which the folder was discarded for good. Keep each folder under observation for a bounded wall-clock window and re-check it on every pass until it expires. This narrows the exposure rather than closing it; only making the emptiness check and the delete atomic would do that. A delete that returned an error was never observed at all, though the redis stores drop the folder before its parent-list member, so a failure return is not proof the folder survived. Record the folder before the delete instead. Restores now run shallowest first, so a folder taken by the parent cascade is rebuilt with its own attributes before anything below it needs it as a parent. Claude-Session: https://claude.ai/code/session_01HdLXMUopwgofPb1ZEmiE6r * filer: recover a deleted folder from the create event for the entry that raced it Checking each deleted folder on a timer was the wrong instrument. It cost a listing per folder per pass, and it could only ever be a guess about when the racing write would land. The metadata stream already carries the answer. A folder is recorded before it is deleted, so any entry that can be orphaned is created after that record and its create event names that exact directory. Match the event against the recently deleted folders and the folder is known to need putting back, rather than inferred to. The window stops being a guess at the race and becomes what it should be: how far behind the event stream is allowed to run before a folder stops being watched. Listing is now done once, for a folder an event has already named, to skip the restore when the entry has since gone away again. Claude-Session: https://claude.ai/code/session_01HdLXMUopwgofPb1ZEmiE6r * filer: bound how long a folder is watched, and rebuild ancestors from themselves Four gaps found reviewing the restore pass. A folder whose restore kept failing was never let go: the written-to check ran before the age check, so it was picked up, retried, put back, and counted again on every pass for the life of the process. Apply the window first, whatever state the folder is in. At the cap, the folder being recorded was the one turned away, though it is the one whose race is still live - the older entries are already close to ageing out. Give up one of those instead, picked as the oldest of a small sample so the cost stays flat under heavy deletion rates. An ancestor taken by the same cascade was left to the descendant's restore to recreate, which minted it from the descendant's attributes and handed back access the ancestor never granted. Rebuild those from what they were, ahead of anything below them. Reading a directory's attributes assumed an entry came back. Some stores return nothing with no error, so treat that as not found. The mode is also taken whole rather than through Perm(), which was dropping setgid, setuid and sticky. Claude-Session: https://claude.ai/code/session_01HdLXMUopwgofPb1ZEmiE6r * redis3: take a directory listing left behind by a failed delete Removing the last name deletes the list, and if that delete fails the header survives pointing at a name that is gone. The retry finds nothing to remove, reports no changes, and returns before reaching the delete, so the key stays for good. Take it on that path too. Claude-Session: https://claude.ai/code/session_01HdLXMUopwgofPb1ZEmiE6r
338 lines
10 KiB
Go
338 lines
10 KiB
Go
package redis2
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
)
|
|
|
|
const (
|
|
DIR_LIST_MARKER = "\x00"
|
|
)
|
|
|
|
type UniversalRedis2Store struct {
|
|
Client redis.UniversalClient
|
|
keyPrefix string
|
|
superLargeDirectoryHash map[string]bool
|
|
}
|
|
|
|
func (store *UniversalRedis2Store) isSuperLargeDirectory(dir string) (isSuperLargeDirectory bool) {
|
|
_, isSuperLargeDirectory = store.superLargeDirectoryHash[dir]
|
|
return
|
|
}
|
|
|
|
func (store *UniversalRedis2Store) loadSuperLargeDirectories(superLargeDirectories []string) {
|
|
// set directory hash
|
|
store.superLargeDirectoryHash = make(map[string]bool)
|
|
for _, dir := range superLargeDirectories {
|
|
store.superLargeDirectoryHash[dir] = true
|
|
}
|
|
}
|
|
|
|
func (store *UniversalRedis2Store) getKey(key string) string {
|
|
if store.keyPrefix == "" {
|
|
return key
|
|
}
|
|
return store.keyPrefix + key
|
|
}
|
|
|
|
func (store *UniversalRedis2Store) BeginTransaction(ctx context.Context) (context.Context, error) {
|
|
return ctx, nil
|
|
}
|
|
func (store *UniversalRedis2Store) CommitTransaction(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
func (store *UniversalRedis2Store) RollbackTransaction(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func (store *UniversalRedis2Store) InsertEntry(ctx context.Context, entry *filer.Entry) (err error) {
|
|
|
|
if err = store.doInsertEntry(ctx, entry); err != nil {
|
|
return err
|
|
}
|
|
|
|
dir, name := entry.FullPath.DirAndName()
|
|
if store.isSuperLargeDirectory(dir) {
|
|
return nil
|
|
}
|
|
|
|
if name != "" {
|
|
if err = store.Client.ZAddNX(ctx, store.getKey(genDirectoryListKey(dir)), redis.Z{Score: 0, Member: name}).Err(); err != nil {
|
|
return fmt.Errorf("persisting %s in parent dir: %v", entry.FullPath, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (store *UniversalRedis2Store) doInsertEntry(ctx context.Context, entry *filer.Entry) error {
|
|
value, err := entry.EncodeAttributesAndChunks()
|
|
if err != nil {
|
|
return fmt.Errorf("encoding %s %+v: %v", entry.FullPath, entry.Attr, err)
|
|
}
|
|
|
|
if len(entry.GetChunks()) > filer.CountEntryChunksForGzip {
|
|
value = util.MaybeGzipData(value)
|
|
}
|
|
|
|
if err = store.Client.Set(ctx, store.getKey(string(entry.FullPath)), value, time.Duration(entry.TtlSec)*time.Second).Err(); err != nil {
|
|
return fmt.Errorf("persisting %s : %v", entry.FullPath, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (store *UniversalRedis2Store) UpdateEntry(ctx context.Context, entry *filer.Entry) (err error) {
|
|
|
|
return store.doInsertEntry(ctx, entry)
|
|
}
|
|
|
|
func (store *UniversalRedis2Store) FindEntry(ctx context.Context, fullpath util.FullPath) (entry *filer.Entry, err error) {
|
|
|
|
data, err := store.Client.Get(ctx, store.getKey(string(fullpath))).Result()
|
|
if err == redis.Nil {
|
|
return nil, filer_pb.ErrNotFound
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get %s : %v", fullpath, err)
|
|
}
|
|
|
|
entry = &filer.Entry{
|
|
FullPath: fullpath,
|
|
}
|
|
err = entry.DecodeAttributesAndChunks(util.MaybeDecompressData([]byte(data)))
|
|
if err != nil {
|
|
return entry, fmt.Errorf("decode %s : %v", entry.FullPath, err)
|
|
}
|
|
|
|
return entry, nil
|
|
}
|
|
|
|
func (store *UniversalRedis2Store) DeleteEntry(ctx context.Context, fullpath util.FullPath) (err error) {
|
|
|
|
// The child listing is dropped by DeleteFolderChildren, together with the
|
|
// children it describes. Dropping it here would also discard an entry that
|
|
// arrived after the caller judged this directory empty, and nothing else
|
|
// records that the entry is there.
|
|
_, err = store.Client.Del(ctx, store.getKey(string(fullpath))).Result()
|
|
if err != nil {
|
|
return fmt.Errorf("delete %s : %v", fullpath, err)
|
|
}
|
|
|
|
dir, name := fullpath.DirAndName()
|
|
if store.isSuperLargeDirectory(dir) {
|
|
return nil
|
|
}
|
|
if name != "" {
|
|
_, err = store.Client.ZRem(ctx, store.getKey(genDirectoryListKey(dir)), name).Result()
|
|
if err != nil {
|
|
return fmt.Errorf("DeleteEntry %s in parent dir: %v", fullpath, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (store *UniversalRedis2Store) DeleteFolderChildren(ctx context.Context, fullpath util.FullPath) (err error) {
|
|
|
|
if store.isSuperLargeDirectory(string(fullpath)) {
|
|
return nil
|
|
}
|
|
|
|
dirListKey := store.getKey(genDirectoryListKey(string(fullpath)))
|
|
members, err := store.Client.ZRangeByLex(ctx, dirListKey, &redis.ZRangeBy{
|
|
Min: "-",
|
|
Max: "+",
|
|
}).Result()
|
|
if err != nil {
|
|
return fmt.Errorf("DeleteFolderChildren %s : %v", fullpath, err)
|
|
}
|
|
|
|
for _, fileName := range members {
|
|
path := util.NewFullPath(string(fullpath), fileName)
|
|
_, err = store.Client.Del(ctx, store.getKey(string(path))).Result()
|
|
if err != nil {
|
|
return fmt.Errorf("DeleteFolderChildren %s in parent dir: %v", fullpath, err)
|
|
}
|
|
// not efficient, but need to remove if it is a directory
|
|
store.Client.Del(ctx, store.getKey(genDirectoryListKey(string(path))))
|
|
}
|
|
|
|
if _, err = store.Client.Del(ctx, dirListKey).Result(); err != nil {
|
|
return fmt.Errorf("DeleteFolderChildren %s list: %v", fullpath, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (store *UniversalRedis2Store) ListDirectoryPrefixedEntries(ctx context.Context, dirPath util.FullPath, startFileName string, includeStartFile bool, limit int64, prefix string, eachEntryFunc filer.ListEachEntryFunc) (lastFileName string, err error) {
|
|
return lastFileName, filer.ErrUnsupportedListDirectoryPrefixed
|
|
}
|
|
|
|
func (store *UniversalRedis2Store) ListDirectoryEntries(ctx context.Context, dirPath util.FullPath, startFileName string, includeStartFile bool, limit int64, eachEntryFunc filer.ListEachEntryFunc) (lastFileName string, err error) {
|
|
|
|
dirListKey := store.getKey(genDirectoryListKey(string(dirPath)))
|
|
|
|
min := "-"
|
|
if startFileName != "" {
|
|
if includeStartFile {
|
|
min = "[" + startFileName
|
|
} else {
|
|
min = "(" + startFileName
|
|
}
|
|
}
|
|
|
|
members, err := store.Client.ZRangeByLex(ctx, dirListKey, &redis.ZRangeBy{
|
|
Min: min,
|
|
Max: "+",
|
|
Offset: 0,
|
|
Count: limit,
|
|
}).Result()
|
|
if err != nil {
|
|
return lastFileName, fmt.Errorf("list %s : %v", dirPath, err)
|
|
}
|
|
|
|
// fetch entry meta
|
|
var entry *filer.Entry
|
|
for _, fileName := range members {
|
|
path := util.NewFullPath(string(dirPath), fileName)
|
|
entry, err = store.FindEntry(ctx, path)
|
|
lastFileName = fileName
|
|
if err != nil {
|
|
glog.V(0).InfofCtx(ctx, "list %s : %v", path, err)
|
|
if err == filer_pb.ErrNotFound {
|
|
store.removeOrphanedDirectoryListMember(ctx, dirPath, fileName)
|
|
err = nil
|
|
continue
|
|
}
|
|
break
|
|
} else {
|
|
if isLogicallyExpired(entry) {
|
|
store.deleteExpiredEntry(ctx, dirPath, path, fileName)
|
|
continue
|
|
}
|
|
|
|
resEachEntryFunc, resEachEntryFuncErr := eachEntryFunc(entry)
|
|
if resEachEntryFuncErr != nil {
|
|
err = fmt.Errorf("failed to process eachEntryFunc: %w", resEachEntryFuncErr)
|
|
break
|
|
}
|
|
|
|
if !resEachEntryFunc {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return lastFileName, err
|
|
}
|
|
|
|
func (store *UniversalRedis2Store) removeOrphanedDirectoryListMember(ctx context.Context, dirPath util.FullPath, fileName string) {
|
|
// a directory converted to super large after accumulating members still has a legacy index
|
|
if store.isSuperLargeDirectory(string(dirPath)) {
|
|
return
|
|
}
|
|
|
|
// survive the listing request being canceled mid-repair
|
|
ctx = context.WithoutCancel(ctx)
|
|
|
|
dirListKey := store.getKey(genDirectoryListKey(string(dirPath)))
|
|
path := util.NewFullPath(string(dirPath), fileName)
|
|
|
|
if err := store.Client.ZRem(ctx, dirListKey, fileName).Err(); err != nil {
|
|
return
|
|
}
|
|
|
|
// InsertEntry writes the value before adding the member, so a value present
|
|
// again here may belong to an insert that found the member still in place
|
|
// and whose ZAddNX was therefore a no-op.
|
|
exists, err := store.existsOnMaster(ctx, store.getKey(string(path)))
|
|
if err == nil && exists == 0 {
|
|
// an evicted directory may still have a live child index; empty zsets self-delete,
|
|
// so a present index holds children a recursive delete still needs to reach
|
|
children, childrenErr := store.existsOnMaster(ctx, store.getKey(genDirectoryListKey(string(path))))
|
|
if childrenErr == nil && children == 0 {
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := store.Client.ZAddNX(ctx, dirListKey, redis.Z{Score: 0, Member: fileName}).Err(); err != nil {
|
|
glog.V(0).InfofCtx(ctx, "restore %s in %s: %v", fileName, dirPath, err)
|
|
}
|
|
}
|
|
|
|
var existsScript = redis.NewScript(`return redis.call('EXISTS', KEYS[1])`)
|
|
|
|
// replica-routed clients (useReadOnly, routeByLatency) would run a plain EXISTS on a lagging
|
|
// replica and misread a live value as absent, turning the repair destructive; a script always
|
|
// runs on the key's master
|
|
func (store *UniversalRedis2Store) existsOnMaster(ctx context.Context, key string) (int64, error) {
|
|
return existsScript.Run(ctx, store.Client, []string{key}).Int64()
|
|
}
|
|
|
|
func isLogicallyExpired(entry *filer.Entry) bool {
|
|
return entry.TtlSec > 0 && entry.Attr.Crtime.Add(time.Duration(entry.TtlSec)*time.Second).Before(time.Now())
|
|
}
|
|
|
|
// deletes the value only when it still holds exactly the bytes the expiry decision was made on;
|
|
// single-key, so it runs on all transports where a multi-key script would be CROSSSLOT.
|
|
// -1: already gone, 0: changed under us, 1: deleted
|
|
var deleteIfUnchangedScript = redis.NewScript(`
|
|
local v = redis.call('GET', KEYS[1])
|
|
if v == false then
|
|
return -1
|
|
end
|
|
if v == ARGV[1] then
|
|
return redis.call('DEL', KEYS[1])
|
|
end
|
|
return 0`)
|
|
|
|
func (store *UniversalRedis2Store) deleteExpiredEntry(ctx context.Context, dirPath util.FullPath, path util.FullPath, fileName string) {
|
|
// survive the listing request being canceled mid-delete
|
|
ctx = context.WithoutCancel(ctx)
|
|
valueKey := store.getKey(string(path))
|
|
|
|
// re-read so the delete can be conditioned on exactly the bytes checked
|
|
data, err := store.Client.Get(ctx, valueKey).Bytes()
|
|
if err == redis.Nil {
|
|
store.removeOrphanedDirectoryListMember(ctx, dirPath, fileName)
|
|
return
|
|
}
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
entry := &filer.Entry{FullPath: path}
|
|
if err := entry.DecodeAttributesAndChunks(util.MaybeDecompressData(data)); err != nil {
|
|
return
|
|
}
|
|
if !isLogicallyExpired(entry) {
|
|
// a concurrent insert recreated it
|
|
return
|
|
}
|
|
|
|
// 0 means a concurrent recreate changed the value: keep it. -1 means the redis
|
|
// TTL won after the re-read: the member still needs the not-found repair.
|
|
deleted, err := deleteIfUnchangedScript.Run(ctx, store.Client, []string{valueKey}, data).Int()
|
|
if err != nil || deleted == 0 {
|
|
return
|
|
}
|
|
store.removeOrphanedDirectoryListMember(ctx, dirPath, fileName)
|
|
}
|
|
|
|
func genDirectoryListKey(dir string) (dirList string) {
|
|
return dir + DIR_LIST_MARKER
|
|
}
|
|
|
|
func (store *UniversalRedis2Store) Shutdown() {
|
|
store.Client.Close()
|
|
}
|