From defb5c06aab363e8c2a09dd3549820ff1abcc655 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Thu, 22 Jan 2026 11:31:44 +0100 Subject: [PATCH 01/39] feat: add .symlinks file support for AWS S3 compatibility --- core/cfg/config.go | 2 + core/cfg/flags.go | 15 +++ core/dir.go | 167 ++++++++++++++++++++++++++++++- core/symlinks.go | 226 ++++++++++++++++++++++++++++++++++++++++++ core/symlinks_test.go | 196 ++++++++++++++++++++++++++++++++++++ 5 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 core/symlinks.go create mode 100644 core/symlinks_test.go diff --git a/core/cfg/config.go b/core/cfg/config.go index f8d3ef95..c4216471 100644 --- a/core/cfg/config.go +++ b/core/cfg/config.go @@ -95,6 +95,8 @@ type FlagStorage struct { RdevAttr string MtimeAttr string SymlinkAttr string + SymlinksFile string // Name of the hidden file storing symlinks metadata + EnableSymlinksFile bool // Use .symlinks file instead of object metadata for symlinks RefreshAttr string RefreshFilename string FlushFilename string diff --git a/core/cfg/flags.go b/core/cfg/flags.go index 22bcb0fb..d943c674 100644 --- a/core/cfg/flags.go +++ b/core/cfg/flags.go @@ -565,6 +565,18 @@ MISC OPTIONS: " Only works correctly if your S3 returns UserMetadata in listings", }, + cli.BoolFlag{ + Name: "enable-symlinks-file", + Usage: "Store symlinks in a hidden .symlinks file per directory instead of object metadata." + + " Useful for S3 backends that don't return UserMetadata in listings (e.g., AWS S3).", + }, + + cli.StringFlag{ + Name: "symlinks-file", + Value: ".symlinks", + Usage: "Name of the hidden file storing symlinks metadata when --enable-symlinks-file is used.", + }, + cli.StringFlag{ Name: "refresh-attr", Value: ".invalidate", @@ -867,6 +879,8 @@ func PopulateFlags(c *cli.Context) (ret *FlagStorage) { RdevAttr: c.String("rdev-attr"), MtimeAttr: c.String("mtime-attr"), SymlinkAttr: c.String("symlink-attr"), + SymlinksFile: c.String("symlinks-file"), + EnableSymlinksFile: c.Bool("enable-symlinks-file"), RefreshAttr: c.String("refresh-attr"), CachePath: c.String("cache"), MaxDiskCacheFD: int64(c.Int("max-disk-cache-fd")), @@ -1071,6 +1085,7 @@ func DefaultFlags() *FlagStorage { RdevAttr: "rdev", MtimeAttr: "mtime", SymlinkAttr: "--symlink-target", + SymlinksFile: ".symlinks", RefreshAttr: ".invalidate", StatCacheTTL: 30 * time.Second, HTTPTimeout: 30 * time.Second, diff --git a/core/dir.go b/core/dir.go index 0afd0e35..53dbf607 100644 --- a/core/dir.go +++ b/core/dir.go @@ -60,6 +60,11 @@ type DirInodeData struct { DeletedChildren map[string]*Inode Gaps []*SlurpGap handles []*DirHandle + + // Symlinks file cache (used when EnableSymlinksFile is true) + symlinksCache *SymlinksFileData + symlinksCacheETag string + symlinksCacheTime time.Time } // Returns the position of first char < '/' in `inp` after prefixLen + any continued '/' characters. @@ -446,11 +451,27 @@ func (dh *DirHandle) handleListResult(resp *ListBlobsOutput, prefix string, skip continue } + // Skip the .symlinks file from listing results + if fs.flags.EnableSymlinksFile && baseName == fs.flags.SymlinksFile { + continue + } + slash := strings.Index(baseName, "/") if slash == -1 { inode := parent.findChildUnlocked(baseName) if inode != nil { inode.SetFromBlobItem(&obj) + // Apply symlink metadata from cache if using symlinks file + if fs.flags.EnableSymlinksFile { + if target, ok := parent.getSymlinkTargetFromCache(baseName); ok { + inode.mu.Lock() + if inode.userMetadata == nil { + inode.userMetadata = make(map[string][]byte) + } + inode.userMetadata[fs.flags.SymlinkAttr] = []byte(target) + inode.mu.Unlock() + } + } } else { // don't revive deleted items _, deleted := parent.dir.DeletedChildren[baseName] @@ -458,6 +479,17 @@ func (dh *DirHandle) handleListResult(resp *ListBlobsOutput, prefix string, skip inode = NewInode(fs, parent, baseName) fs.insertInode(parent, inode) inode.SetFromBlobItem(&obj) + // Apply symlink metadata from cache if using symlinks file + if fs.flags.EnableSymlinksFile { + if target, ok := parent.getSymlinkTargetFromCache(baseName); ok { + inode.mu.Lock() + if inode.userMetadata == nil { + inode.userMetadata = make(map[string][]byte) + } + inode.userMetadata[fs.flags.SymlinkAttr] = []byte(target) + inode.mu.Unlock() + } + } } } } else { @@ -652,6 +684,14 @@ func (dh *DirHandle) checkDirPosition() { func (dh *DirHandle) loadListing() error { parent := dh.inode + // Load symlinks file cache if enabled and not already loaded + if parent.fs.flags.EnableSymlinksFile { + if err := parent.loadSymlinksCache(); err != nil { + s3Log.Warnf("Failed to load symlinks cache for %v: %v", parent.FullName(), err) + // Continue anyway, symlinks just won't work + } + } + if !parent.dir.listDone && parent.dir.listMarker == "" { // listMarker is nil => We just started refreshing this directory parent.dir.listDone = false @@ -1115,6 +1155,20 @@ func (parent *Inode) Unlink(name string) (err error) { inode := parent.findChildUnlocked(name) if inode != nil { fuseLog.Debugf("Unlink %v", inode.FullName()) + + // If this is a symlink and using symlinks file, update the file + if parent.fs.flags.EnableSymlinksFile { + inode.mu.Lock() + isSymlink := inode.userMetadata != nil && inode.userMetadata[parent.fs.flags.SymlinkAttr] != nil + inode.mu.Unlock() + if isSymlink { + if err := parent.updateSymlinksFile(name, "", true); err != nil { + s3Log.Warnf("Failed to update symlinks file for unlink %v: %v", name, err) + // Continue anyway, the symlink entry will be orphaned but the file will be deleted + } + } + } + inode.mu.Lock() inode.doUnlink() inode.mu.Unlock() @@ -1368,7 +1422,18 @@ func (parent *Inode) CreateSymlink( inode = NewInode(fs, parent, name) inode.userMetadata = make(map[string][]byte) inode.userMetadata[inode.fs.flags.SymlinkAttr] = []byte(target) - inode.userMetadataDirty = 2 + + // If using symlinks file, update it + if fs.flags.EnableSymlinksFile { + if err := parent.updateSymlinksFile(name, target, false); err != nil { + return nil, err + } + // Don't mark metadata as dirty since we're using the symlinks file + inode.userMetadataDirty = 0 + } else { + inode.userMetadataDirty = 2 + } + inode.mu.Lock() defer inode.mu.Unlock() inode.Attributes = InodeAttributes{ @@ -1391,6 +1456,106 @@ func (parent *Inode) CreateSymlink( return inode, nil } +// updateSymlinksFile updates the .symlinks file in this directory +// LOCKS_REQUIRED(parent.mu) +func (parent *Inode) updateSymlinksFile(name string, target string, remove bool) error { + cloud, dirKey := parent.cloud() + if cloud == nil { + return syscall.ESTALE + } + + // Remove trailing slash from dirKey for consistency + dirKey = strings.TrimSuffix(dirKey, "/") + + symlinksFileName := parent.fs.flags.SymlinksFile + + // Load or use cached symlinks data + var data *SymlinksFileData + var etag string + var err error + + if parent.dir.symlinksCache != nil { + data = parent.dir.symlinksCache + etag = parent.dir.symlinksCacheETag + } else { + data, etag, err = LoadSymlinksFile(cloud, dirKey, symlinksFileName) + if err != nil { + return err + } + } + + // Update the data + if remove { + data.RemoveSymlink(name) + } else { + data.AddSymlink(name, target) + } + + // Save with conditional write + newETag, err := SaveSymlinksFile(cloud, dirKey, symlinksFileName, data, etag) + if err != nil { + return err + } + + // Update cache + parent.dir.symlinksCache = data + parent.dir.symlinksCacheETag = newETag + parent.dir.symlinksCacheTime = time.Now() + + return nil +} + +// loadSymlinksCache loads the symlinks file cache for this directory if needed +// LOCKS_REQUIRED(parent.mu) +func (parent *Inode) loadSymlinksCache() error { + if !parent.fs.flags.EnableSymlinksFile { + return nil + } + + // Check if cache is still valid (within stat-cache-ttl) + if parent.dir.symlinksCache != nil && + time.Since(parent.dir.symlinksCacheTime) < parent.fs.flags.StatCacheTTL { + return nil + } + + cloud, dirKey := parent.cloud() + if cloud == nil { + return syscall.ESTALE + } + + dirKey = strings.TrimSuffix(dirKey, "/") + symlinksFileName := parent.fs.flags.SymlinksFile + + data, etag, err := LoadSymlinksFile(cloud, dirKey, symlinksFileName) + if err != nil { + return err + } + + parent.dir.symlinksCache = data + parent.dir.symlinksCacheETag = etag + parent.dir.symlinksCacheTime = time.Now() + + return nil +} + +// getSymlinkTarget returns the symlink target from the symlinks file cache +// LOCKS_REQUIRED(parent.mu) +func (parent *Inode) getSymlinkTargetFromCache(name string) (string, bool) { + if parent.dir.symlinksCache == nil { + return "", false + } + return parent.dir.symlinksCache.GetSymlink(name) +} + +// isSymlinkFromCache checks if a file is a symlink according to the symlinks file +// LOCKS_REQUIRED(parent.mu) +func (parent *Inode) isSymlinkFromCache(name string) bool { + if parent.dir.symlinksCache == nil { + return false + } + return parent.dir.symlinksCache.HasSymlink(name) +} + func (inode *Inode) ReadSymlink() (target string, err error) { inode.mu.Lock() defer inode.mu.Unlock() diff --git a/core/symlinks.go b/core/symlinks.go new file mode 100644 index 00000000..405ff2e0 --- /dev/null +++ b/core/symlinks.go @@ -0,0 +1,226 @@ +// Copyright 2026 Dectris Ltd. +// +// Licensed 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 core + +import ( + "bytes" + "encoding/json" + "io" + "strings" + "sync" + "time" +) + +// SymlinkEntry represents a single symlink in the .symlinks file +type SymlinkEntry struct { + Target string `json:"target"` + Mtime int64 `json:"mtime"` +} + +// SymlinksFileData represents the content of a .symlinks file +type SymlinksFileData struct { + Version int `json:"version"` + Symlinks map[string]SymlinkEntry `json:"symlinks"` +} + +// SymlinksFileCache caches the .symlinks file data for a directory +type SymlinksFileCache struct { + mu sync.RWMutex + data *SymlinksFileData + etag string + loadTime time.Time +} + +// NewSymlinksFileData creates a new empty symlinks file data structure +func NewSymlinksFileData() *SymlinksFileData { + return &SymlinksFileData{ + Version: 1, + Symlinks: make(map[string]SymlinkEntry), + } +} + +// ParseSymlinksFile parses a .symlinks file content +func ParseSymlinksFile(data []byte) (*SymlinksFileData, error) { + if len(data) == 0 { + return NewSymlinksFileData(), nil + } + + var result SymlinksFileData + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + + if result.Symlinks == nil { + result.Symlinks = make(map[string]SymlinkEntry) + } + + return &result, nil +} + +// Serialize converts the symlinks data to JSON bytes +func (s *SymlinksFileData) Serialize() ([]byte, error) { + return json.MarshalIndent(s, "", " ") +} + +// AddSymlink adds or updates a symlink entry +func (s *SymlinksFileData) AddSymlink(name, target string) { + s.Symlinks[name] = SymlinkEntry{ + Target: target, + Mtime: time.Now().Unix(), + } +} + +// RemoveSymlink removes a symlink entry +func (s *SymlinksFileData) RemoveSymlink(name string) { + delete(s.Symlinks, name) +} + +// GetSymlink returns the target for a symlink, or empty string if not found +func (s *SymlinksFileData) GetSymlink(name string) (string, bool) { + entry, ok := s.Symlinks[name] + if !ok { + return "", false + } + return entry.Target, true +} + +// HasSymlink checks if a symlink exists +func (s *SymlinksFileData) HasSymlink(name string) bool { + _, ok := s.Symlinks[name] + return ok +} + +// IsEmpty returns true if there are no symlinks +func (s *SymlinksFileData) IsEmpty() bool { + return len(s.Symlinks) == 0 +} + +// getSymlinksFilePath returns the full key path for the .symlinks file in a directory +func getSymlinksFilePath(dirKey string, symlinksFileName string) string { + if dirKey == "" { + return symlinksFileName + } + if !strings.HasSuffix(dirKey, "/") { + dirKey += "/" + } + return dirKey + symlinksFileName +} + +// LoadSymlinksFile loads the .symlinks file from the cloud storage +// Returns the parsed data, the ETag (for conditional updates), and any error +func LoadSymlinksFile(cloud StorageBackend, dirKey string, symlinksFileName string) (*SymlinksFileData, string, error) { + key := getSymlinksFilePath(dirKey, symlinksFileName) + + resp, err := cloud.GetBlob(&GetBlobInput{ + Key: key, + Start: 0, + Count: 0, // Read entire file + }) + + if err != nil { + // If file doesn't exist, return empty data + if isNotExist(err) { + return NewSymlinksFileData(), "", nil + } + return nil, "", err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + + parsed, err := ParseSymlinksFile(data) + if err != nil { + return nil, "", err + } + + etag := "" + if resp.ETag != nil { + etag = *resp.ETag + } + + return parsed, etag, nil +} + +// SaveSymlinksFile saves the .symlinks file to cloud storage with conditional write +// If expectedETag is non-empty, uses conditional PUT to avoid race conditions +// Returns the new ETag and any error +func SaveSymlinksFile(cloud StorageBackend, dirKey string, symlinksFileName string, data *SymlinksFileData, expectedETag string) (string, error) { + key := getSymlinksFilePath(dirKey, symlinksFileName) + + // If there are no symlinks and this is a new file, don't create it + if data.IsEmpty() && expectedETag == "" { + return "", nil + } + + // If there are no symlinks, delete the file + if data.IsEmpty() { + _, err := cloud.DeleteBlob(&DeleteBlobInput{Key: key}) + if err != nil && !isNotExist(err) { + return "", err + } + return "", nil + } + + content, err := data.Serialize() + if err != nil { + return "", err + } + + // Note: S3 doesn't support conditional PUT with If-Match directly in PutBlob + // For true atomicity, we would need to use S3's conditional writes feature + // or implement a read-modify-write with retry logic + // For now, we do a simple PUT + resp, err := cloud.PutBlob(&PutBlobInput{ + Key: key, + Body: bytes.NewReader(content), + Size: PUInt64(uint64(len(content))), + }) + + if err != nil { + return "", err + } + + newETag := "" + if resp.ETag != nil { + newETag = *resp.ETag + } + + return newETag, nil +} + +// DeleteSymlinksFile removes the .symlinks file from cloud storage +func DeleteSymlinksFile(cloud StorageBackend, dirKey string, symlinksFileName string) error { + key := getSymlinksFilePath(dirKey, symlinksFileName) + _, err := cloud.DeleteBlob(&DeleteBlobInput{Key: key}) + if err != nil && !isNotExist(err) { + return err + } + return nil +} + +// Helper to check if an error indicates the object doesn't exist +func isNotExist(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return strings.Contains(errStr, "NoSuchKey") || + strings.Contains(errStr, "NotFound") || + strings.Contains(errStr, "404") || + strings.Contains(errStr, "does not exist") +} diff --git a/core/symlinks_test.go b/core/symlinks_test.go new file mode 100644 index 00000000..5578f092 --- /dev/null +++ b/core/symlinks_test.go @@ -0,0 +1,196 @@ +// Copyright 2026 Dectris Ltd. +// +// Licensed 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 core + +import ( + "testing" + + . "gopkg.in/check.v1" +) + +type SymlinksTest struct{} + +var _ = Suite(&SymlinksTest{}) + +func TestSymlinks(t *testing.T) { + TestingT(t) +} + +func (s *SymlinksTest) TestNewSymlinksFileData(t *C) { + data := NewSymlinksFileData() + t.Assert(data.Version, Equals, 1) + t.Assert(len(data.Symlinks), Equals, 0) + t.Assert(data.IsEmpty(), Equals, true) +} + +func (s *SymlinksTest) TestAddSymlink(t *C) { + data := NewSymlinksFileData() + data.AddSymlink("link1", "../target1") + + t.Assert(data.IsEmpty(), Equals, false) + t.Assert(data.HasSymlink("link1"), Equals, true) + t.Assert(data.HasSymlink("link2"), Equals, false) + + target, ok := data.GetSymlink("link1") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "../target1") +} + +func (s *SymlinksTest) TestRemoveSymlink(t *C) { + data := NewSymlinksFileData() + data.AddSymlink("link1", "../target1") + data.AddSymlink("link2", "/absolute/path") + + t.Assert(len(data.Symlinks), Equals, 2) + + data.RemoveSymlink("link1") + t.Assert(len(data.Symlinks), Equals, 1) + t.Assert(data.HasSymlink("link1"), Equals, false) + t.Assert(data.HasSymlink("link2"), Equals, true) + + data.RemoveSymlink("link2") + t.Assert(data.IsEmpty(), Equals, true) +} + +func (s *SymlinksTest) TestSerializeAndParse(t *C) { + data := NewSymlinksFileData() + data.AddSymlink("link1", "../target1") + data.AddSymlink("link2", "/absolute/path") + + bytes, err := data.Serialize() + t.Assert(err, IsNil) + t.Assert(len(bytes) > 0, Equals, true) + + parsed, err := ParseSymlinksFile(bytes) + t.Assert(err, IsNil) + t.Assert(parsed.Version, Equals, 1) + t.Assert(len(parsed.Symlinks), Equals, 2) + + target1, ok := parsed.GetSymlink("link1") + t.Assert(ok, Equals, true) + t.Assert(target1, Equals, "../target1") + + target2, ok := parsed.GetSymlink("link2") + t.Assert(ok, Equals, true) + t.Assert(target2, Equals, "/absolute/path") +} + +func (s *SymlinksTest) TestParseEmptyData(t *C) { + data, err := ParseSymlinksFile([]byte{}) + t.Assert(err, IsNil) + t.Assert(data.IsEmpty(), Equals, true) +} + +func (s *SymlinksTest) TestParseInvalidJSON(t *C) { + _, err := ParseSymlinksFile([]byte("not valid json")) + t.Assert(err, NotNil) +} + +func (s *SymlinksTest) TestGetSymlinksFilePath(t *C) { + t.Assert(getSymlinksFilePath("", ".symlinks"), Equals, ".symlinks") + t.Assert(getSymlinksFilePath("dir", ".symlinks"), Equals, "dir/.symlinks") + t.Assert(getSymlinksFilePath("dir/", ".symlinks"), Equals, "dir/.symlinks") + t.Assert(getSymlinksFilePath("path/to/dir", ".symlinks"), Equals, "path/to/dir/.symlinks") +} + +func (s *SymlinksTest) TestSymlinksToFilesAndDirectories(t *C) { + data := NewSymlinksFileData() + + // Add symlinks to files + data.AddSymlink("link-to-file", "../file.txt") + data.AddSymlink("link-to-nested-file", "../../other/path/file.dat") + + // Add symlinks to directories (relative) + data.AddSymlink("link-to-dir", "../other-folder") + data.AddSymlink("link-to-nested-dir", "../../parent/sibling-folder") + + // Add symlinks to directories (absolute) + data.AddSymlink("link-to-abs-dir", "/absolute/path/to/folder") + + // Verify all symlinks are stored correctly + t.Assert(len(data.Symlinks), Equals, 5) + + // File symlinks + target, ok := data.GetSymlink("link-to-file") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "../file.txt") + + target, ok = data.GetSymlink("link-to-nested-file") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "../../other/path/file.dat") + + // Directory symlinks (relative) + target, ok = data.GetSymlink("link-to-dir") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "../other-folder") + + target, ok = data.GetSymlink("link-to-nested-dir") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "../../parent/sibling-folder") + + // Directory symlinks (absolute) + target, ok = data.GetSymlink("link-to-abs-dir") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "/absolute/path/to/folder") + + // Serialize and parse to ensure persistence works + bytes, err := data.Serialize() + t.Assert(err, IsNil) + + parsed, err := ParseSymlinksFile(bytes) + t.Assert(err, IsNil) + t.Assert(len(parsed.Symlinks), Equals, 5) + + // Verify directory symlinks survive serialization + target, ok = parsed.GetSymlink("link-to-dir") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "../other-folder") + + target, ok = parsed.GetSymlink("link-to-abs-dir") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "/absolute/path/to/folder") +} + +func (s *SymlinksTest) TestSymlinkTargetWithSpecialCharacters(t *C) { + data := NewSymlinksFileData() + + // Test targets with spaces + data.AddSymlink("link-with-space", "../folder with spaces/file.txt") + + // Test targets with unicode + data.AddSymlink("link-unicode", "../données/fichier.txt") + + // Test targets with dots + data.AddSymlink("link-dots", "../.hidden-folder/.hidden-file") + + // Serialize and parse + bytes, err := data.Serialize() + t.Assert(err, IsNil) + + parsed, err := ParseSymlinksFile(bytes) + t.Assert(err, IsNil) + + target, ok := parsed.GetSymlink("link-with-space") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "../folder with spaces/file.txt") + + target, ok = parsed.GetSymlink("link-unicode") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "../données/fichier.txt") + + target, ok = parsed.GetSymlink("link-dots") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "../.hidden-folder/.hidden-file") +} From 2b79a9dd5856c4b46d49fe519d06f150e01c0565 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Mon, 26 Jan 2026 17:25:23 +0100 Subject: [PATCH 02/39] fixes --- core/backend.go | 7 + core/backend_s3.go | 15 +- core/backend_test.go | 588 +++++++++++++++++++++ core/symlinks.go | 130 ++++- core/symlinks_test.go | 392 +++++++++++++- s3ext/models/apis/s3/2006-03-01/api-2.json | 20 + s3ext/service/s3/api.go | 32 ++ s3ext/service/s3/s3manager/upload_input.go | 4 + 8 files changed, 1176 insertions(+), 12 deletions(-) diff --git a/core/backend.go b/core/backend.go index aaa5b235..dcb78321 100644 --- a/core/backend.go +++ b/core/backend.go @@ -138,6 +138,13 @@ type PutBlobInput struct { Body io.ReadSeeker Size *uint64 + + // IfMatch specifies an ETag; the request succeeds only if the object's ETag matches. + // Used for optimistic locking / conditional updates. + IfMatch *string + // IfNoneMatch specifies that the request should succeed only if the object does not exist. + // Set to "*" to prevent overwriting an existing object. + IfNoneMatch *string } type PutBlobOutput struct { diff --git a/core/backend_s3.go b/core/backend_s3.go index a25ea8c6..f0221917 100644 --- a/core/backend_s3.go +++ b/core/backend_s3.go @@ -925,7 +925,10 @@ func (s *S3Backend) GetBlob(param *GetBlobInput) (*GetBlobOutput, error) { } get.Range = &bytes } - // TODO handle IfMatch + + if param.IfMatch != nil { + get.IfMatch = param.IfMatch + } req, resp := s.GetObjectRequest(&get) err := req.Send() @@ -990,6 +993,16 @@ func (s *S3Backend) PutBlob(param *PutBlobInput) (*PutBlobOutput, error) { put.ACL = &s.config.ACL } + // Conditional write support (S3 feature since August 2024) + // IfMatch: only write if ETag matches (optimistic locking for updates) + // IfNoneMatch: only write if object doesn't exist (create-if-not-exists) + if param.IfMatch != nil { + put.IfMatch = param.IfMatch + } + if param.IfNoneMatch != nil { + put.IfNoneMatch = param.IfNoneMatch + } + req, resp := s.PutObjectRequest(put) err := req.Send() if err != nil { diff --git a/core/backend_test.go b/core/backend_test.go index a2f907c9..3d97e502 100644 --- a/core/backend_test.go +++ b/core/backend_test.go @@ -16,6 +16,21 @@ package core +import ( + "bytes" + "fmt" + "io" + "sync" + "syscall" + "time" + + . "gopkg.in/check.v1" +) + +// ============================================================================ +// TestBackend - Generic mock with function hooks (used by other tests) +// ============================================================================ + type TestBackend struct { StorageBackend ListBlobsFunc func(param *ListBlobsInput) (*ListBlobsOutput, error) @@ -163,3 +178,576 @@ func (s *TestBackend) MultipartExpire(param *MultipartExpireInput) (*MultipartEx } return s.StorageBackend.MultipartExpire(param) } + +// ============================================================================ +// BackendTest - Test suite for StorageBackend interface tests +// ============================================================================ + +type BackendTest struct{} + +var _ = Suite(&BackendTest{}) + +// ============================================================================ +// mockConditionalBackend - Full mock with in-memory storage and conditional writes +// ============================================================================ + +// mockConditionalBackend implements StorageBackend interface for testing conditional writes. +// It simulates S3/Azure conditional write behavior with If-Match and If-None-Match headers. +type mockConditionalBackend struct { + mu sync.Mutex + objects map[string]*mockStoredObject + + // Hooks for testing - allow inspection of parameters + onGetBlob func(param *GetBlobInput) + onPutBlob func(param *PutBlobInput) +} + +type mockStoredObject struct { + data []byte + etag string +} + +func newMockConditionalBackend() *mockConditionalBackend { + return &mockConditionalBackend{ + objects: make(map[string]*mockStoredObject), + } +} + +func (m *mockConditionalBackend) generateETag() string { + return fmt.Sprintf("\"%d\"", time.Now().UnixNano()) +} + +func (m *mockConditionalBackend) Init(key string) error { return nil } +func (m *mockConditionalBackend) Capabilities() *Capabilities { + return &Capabilities{Name: "mock-conditional"} +} +func (m *mockConditionalBackend) Bucket() string { return "mock-bucket" } + +func (m *mockConditionalBackend) HeadBlob(param *HeadBlobInput) (*HeadBlobOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + obj, exists := m.objects[param.Key] + if !exists { + return nil, syscall.ENOENT + } + return &HeadBlobOutput{ + BlobItemOutput: BlobItemOutput{ + Key: ¶m.Key, + ETag: &obj.etag, + Size: uint64(len(obj.data)), + }, + }, nil +} + +func (m *mockConditionalBackend) ListBlobs(param *ListBlobsInput) (*ListBlobsOutput, error) { + return &ListBlobsOutput{}, nil +} + +func (m *mockConditionalBackend) DeleteBlob(param *DeleteBlobInput) (*DeleteBlobOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.objects, param.Key) + return &DeleteBlobOutput{}, nil +} + +func (m *mockConditionalBackend) DeleteBlobs(param *DeleteBlobsInput) (*DeleteBlobsOutput, error) { + return &DeleteBlobsOutput{}, nil +} + +func (m *mockConditionalBackend) RenameBlob(param *RenameBlobInput) (*RenameBlobOutput, error) { + return &RenameBlobOutput{}, nil +} + +func (m *mockConditionalBackend) CopyBlob(param *CopyBlobInput) (*CopyBlobOutput, error) { + return &CopyBlobOutput{}, nil +} + +func (m *mockConditionalBackend) GetBlob(param *GetBlobInput) (*GetBlobOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Call hook if set + if m.onGetBlob != nil { + m.onGetBlob(param) + } + + obj, exists := m.objects[param.Key] + if !exists { + return nil, syscall.ENOENT + } + + // Check IfMatch condition - only return if ETag matches + if param.IfMatch != nil && *param.IfMatch != obj.etag { + return nil, fmt.Errorf("PreconditionFailed: ETag mismatch for GetBlob") + } + + return &GetBlobOutput{ + HeadBlobOutput: HeadBlobOutput{ + BlobItemOutput: BlobItemOutput{ + Key: ¶m.Key, + ETag: &obj.etag, + Size: uint64(len(obj.data)), + }, + }, + Body: io.NopCloser(bytes.NewReader(obj.data)), + }, nil +} + +func (m *mockConditionalBackend) PutBlob(param *PutBlobInput) (*PutBlobOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Call hook if set + if m.onPutBlob != nil { + m.onPutBlob(param) + } + + obj, exists := m.objects[param.Key] + + // Check If-None-Match: "*" condition (create-if-not-exists) + // Returns 412 Precondition Failed if object already exists + if param.IfNoneMatch != nil && *param.IfNoneMatch == "*" { + if exists { + return nil, fmt.Errorf("PreconditionFailed: object already exists") + } + } + + // Check If-Match condition (optimistic locking) + // Returns 412 Precondition Failed if ETag doesn't match + if param.IfMatch != nil { + if !exists { + return nil, fmt.Errorf("PreconditionFailed: object does not exist for If-Match") + } + if *param.IfMatch != obj.etag { + return nil, fmt.Errorf("PreconditionFailed: ETag mismatch, expected %s, got %s", *param.IfMatch, obj.etag) + } + } + + // Read the body + data, err := io.ReadAll(param.Body) + if err != nil { + return nil, err + } + + etag := m.generateETag() + m.objects[param.Key] = &mockStoredObject{ + data: data, + etag: etag, + } + + return &PutBlobOutput{ + ETag: &etag, + }, nil +} + +func (m *mockConditionalBackend) PatchBlob(param *PatchBlobInput) (*PatchBlobOutput, error) { + return &PatchBlobOutput{}, nil +} + +func (m *mockConditionalBackend) MultipartBlobBegin(param *MultipartBlobBeginInput) (*MultipartBlobCommitInput, error) { + return &MultipartBlobCommitInput{}, nil +} + +func (m *mockConditionalBackend) MultipartBlobAdd(param *MultipartBlobAddInput) (*MultipartBlobAddOutput, error) { + return &MultipartBlobAddOutput{}, nil +} + +func (m *mockConditionalBackend) MultipartBlobCopy(param *MultipartBlobCopyInput) (*MultipartBlobCopyOutput, error) { + return &MultipartBlobCopyOutput{}, nil +} + +func (m *mockConditionalBackend) MultipartBlobAbort(param *MultipartBlobCommitInput) (*MultipartBlobAbortOutput, error) { + return &MultipartBlobAbortOutput{}, nil +} + +func (m *mockConditionalBackend) MultipartBlobCommit(param *MultipartBlobCommitInput) (*MultipartBlobCommitOutput, error) { + return &MultipartBlobCommitOutput{}, nil +} + +func (m *mockConditionalBackend) MultipartExpire(param *MultipartExpireInput) (*MultipartExpireOutput, error) { + return &MultipartExpireOutput{}, nil +} + +func (m *mockConditionalBackend) RemoveBucket(param *RemoveBucketInput) (*RemoveBucketOutput, error) { + return &RemoveBucketOutput{}, nil +} + +func (m *mockConditionalBackend) MakeBucket(param *MakeBucketInput) (*MakeBucketOutput, error) { + return &MakeBucketOutput{}, nil +} + +func (m *mockConditionalBackend) Delegate() interface{} { + return nil +} + +// ============================================================================ +// Tests for PutBlob with IfNoneMatch (create-if-not-exists) +// ============================================================================ + +func (s *BackendTest) TestPutBlobIfNoneMatchCreatesNewObject(t *C) { + mock := newMockConditionalBackend() + + ifNoneMatch := "*" + resp, err := mock.PutBlob(&PutBlobInput{ + Key: "test-key", + Body: bytes.NewReader([]byte("test data")), + IfNoneMatch: &ifNoneMatch, + }) + + t.Assert(err, IsNil) + t.Assert(resp.ETag, NotNil) + t.Assert(*resp.ETag != "", Equals, true) + + // Verify object was created + _, exists := mock.objects["test-key"] + t.Assert(exists, Equals, true) +} + +func (s *BackendTest) TestPutBlobIfNoneMatchFailsIfExists(t *C) { + mock := newMockConditionalBackend() + + // Pre-create an object + mock.objects["test-key"] = &mockStoredObject{ + data: []byte("existing data"), + etag: "\"existing-etag\"", + } + + ifNoneMatch := "*" + _, err := mock.PutBlob(&PutBlobInput{ + Key: "test-key", + Body: bytes.NewReader([]byte("new data")), + IfNoneMatch: &ifNoneMatch, + }) + + t.Assert(err, NotNil) + t.Assert(err.Error(), Matches, ".*PreconditionFailed.*") + + // Verify original data is unchanged + t.Assert(string(mock.objects["test-key"].data), Equals, "existing data") +} + +func (s *BackendTest) TestPutBlobWithoutConditionsOverwrites(t *C) { + mock := newMockConditionalBackend() + + // Pre-create an object + mock.objects["test-key"] = &mockStoredObject{ + data: []byte("existing data"), + etag: "\"existing-etag\"", + } + + // Put without conditions - should overwrite + resp, err := mock.PutBlob(&PutBlobInput{ + Key: "test-key", + Body: bytes.NewReader([]byte("new data")), + }) + + t.Assert(err, IsNil) + t.Assert(resp.ETag, NotNil) + t.Assert(string(mock.objects["test-key"].data), Equals, "new data") +} + +// ============================================================================ +// Tests for PutBlob with IfMatch (optimistic locking) +// ============================================================================ + +func (s *BackendTest) TestPutBlobIfMatchSucceedsWithCorrectETag(t *C) { + mock := newMockConditionalBackend() + + // Pre-create an object + existingETag := "\"existing-etag\"" + mock.objects["test-key"] = &mockStoredObject{ + data: []byte("existing data"), + etag: existingETag, + } + + // Update with correct ETag + resp, err := mock.PutBlob(&PutBlobInput{ + Key: "test-key", + Body: bytes.NewReader([]byte("updated data")), + IfMatch: &existingETag, + }) + + t.Assert(err, IsNil) + t.Assert(resp.ETag, NotNil) + t.Assert(*resp.ETag != existingETag, Equals, true) // New ETag should be different + t.Assert(string(mock.objects["test-key"].data), Equals, "updated data") +} + +func (s *BackendTest) TestPutBlobIfMatchFailsWithWrongETag(t *C) { + mock := newMockConditionalBackend() + + // Pre-create an object + mock.objects["test-key"] = &mockStoredObject{ + data: []byte("existing data"), + etag: "\"actual-etag\"", + } + + // Try to update with wrong ETag + wrongETag := "\"wrong-etag\"" + _, err := mock.PutBlob(&PutBlobInput{ + Key: "test-key", + Body: bytes.NewReader([]byte("updated data")), + IfMatch: &wrongETag, + }) + + t.Assert(err, NotNil) + t.Assert(err.Error(), Matches, ".*PreconditionFailed.*ETag mismatch.*") + + // Verify original data is unchanged + t.Assert(string(mock.objects["test-key"].data), Equals, "existing data") +} + +func (s *BackendTest) TestPutBlobIfMatchFailsIfObjectNotExists(t *C) { + mock := newMockConditionalBackend() + + // Try to update non-existent object with If-Match + etag := "\"some-etag\"" + _, err := mock.PutBlob(&PutBlobInput{ + Key: "non-existent-key", + Body: bytes.NewReader([]byte("data")), + IfMatch: &etag, + }) + + t.Assert(err, NotNil) + t.Assert(err.Error(), Matches, ".*PreconditionFailed.*does not exist.*") +} + +// ============================================================================ +// Tests for GetBlob with IfMatch +// ============================================================================ + +func (s *BackendTest) TestGetBlobIfMatchSucceedsWithCorrectETag(t *C) { + mock := newMockConditionalBackend() + + existingETag := "\"test-etag\"" + mock.objects["test-key"] = &mockStoredObject{ + data: []byte("test data"), + etag: existingETag, + } + + resp, err := mock.GetBlob(&GetBlobInput{ + Key: "test-key", + IfMatch: &existingETag, + }) + + t.Assert(err, IsNil) + t.Assert(resp.ETag, NotNil) + t.Assert(*resp.ETag, Equals, existingETag) + + data, _ := io.ReadAll(resp.Body) + t.Assert(string(data), Equals, "test data") +} + +func (s *BackendTest) TestGetBlobIfMatchFailsWithWrongETag(t *C) { + mock := newMockConditionalBackend() + + mock.objects["test-key"] = &mockStoredObject{ + data: []byte("test data"), + etag: "\"actual-etag\"", + } + + wrongETag := "\"wrong-etag\"" + _, err := mock.GetBlob(&GetBlobInput{ + Key: "test-key", + IfMatch: &wrongETag, + }) + + t.Assert(err, NotNil) + t.Assert(err.Error(), Matches, ".*PreconditionFailed.*") +} + +func (s *BackendTest) TestGetBlobWithoutConditions(t *C) { + mock := newMockConditionalBackend() + + existingETag := "\"test-etag\"" + mock.objects["test-key"] = &mockStoredObject{ + data: []byte("test data"), + etag: existingETag, + } + + // Get without IfMatch - should always succeed + resp, err := mock.GetBlob(&GetBlobInput{ + Key: "test-key", + }) + + t.Assert(err, IsNil) + t.Assert(resp.ETag, NotNil) +} + +// ============================================================================ +// Tests for optimistic locking pattern (read-modify-write) +// ============================================================================ + +func (s *BackendTest) TestOptimisticLockingPattern(t *C) { + mock := newMockConditionalBackend() + + // Initial create + ifNoneMatch := "*" + resp1, err := mock.PutBlob(&PutBlobInput{ + Key: "shared-resource", + Body: bytes.NewReader([]byte("initial value")), + IfNoneMatch: &ifNoneMatch, + }) + t.Assert(err, IsNil) + etag1 := *resp1.ETag + + // Client A reads and gets ETag + getResp, err := mock.GetBlob(&GetBlobInput{Key: "shared-resource"}) + t.Assert(err, IsNil) + clientAETag := *getResp.ETag + getResp.Body.Close() + + // Client B reads and gets same ETag + getResp2, err := mock.GetBlob(&GetBlobInput{Key: "shared-resource"}) + t.Assert(err, IsNil) + clientBETag := *getResp2.ETag + getResp2.Body.Close() + + t.Assert(clientAETag, Equals, clientBETag) + t.Assert(clientAETag, Equals, etag1) + + // Client A updates successfully + resp2, err := mock.PutBlob(&PutBlobInput{ + Key: "shared-resource", + Body: bytes.NewReader([]byte("client A update")), + IfMatch: &clientAETag, + }) + t.Assert(err, IsNil) + newETag := *resp2.ETag + t.Assert(newETag != etag1, Equals, true) + + // Client B tries to update with stale ETag - should fail + _, err = mock.PutBlob(&PutBlobInput{ + Key: "shared-resource", + Body: bytes.NewReader([]byte("client B update")), + IfMatch: &clientBETag, // This is now stale + }) + t.Assert(err, NotNil) + t.Assert(err.Error(), Matches, ".*PreconditionFailed.*") + + // Verify Client A's update persisted + t.Assert(string(mock.objects["shared-resource"].data), Equals, "client A update") +} + +func (s *BackendTest) TestOptimisticLockingRetryPattern(t *C) { + mock := newMockConditionalBackend() + + // Initial create + ifNoneMatch := "*" + resp, err := mock.PutBlob(&PutBlobInput{ + Key: "counter", + Body: bytes.NewReader([]byte("0")), + IfNoneMatch: &ifNoneMatch, + }) + t.Assert(err, IsNil) + currentETag := *resp.ETag + + // Simulate a retry loop for incrementing a counter + maxRetries := 3 + success := false + + for i := 0; i < maxRetries; i++ { + // Read current value + getResp, err := mock.GetBlob(&GetBlobInput{Key: "counter"}) + t.Assert(err, IsNil) + data, _ := io.ReadAll(getResp.Body) + getResp.Body.Close() + readETag := *getResp.ETag + + // Modify value + newValue := string(data) + "+1" + + // Try to write with If-Match + putResp, err := mock.PutBlob(&PutBlobInput{ + Key: "counter", + Body: bytes.NewReader([]byte(newValue)), + IfMatch: &readETag, + }) + + if err == nil { + currentETag = *putResp.ETag + success = true + break + } + // If failed due to conflict, retry + } + + t.Assert(success, Equals, true) + t.Assert(string(mock.objects["counter"].data), Equals, "0+1") + _ = currentETag // Used in real scenarios for subsequent operations +} + +// ============================================================================ +// Tests for hook inspection +// ============================================================================ + +func (s *BackendTest) TestPutBlobHookInspection(t *C) { + mock := newMockConditionalBackend() + + var capturedIfMatch *string + var capturedIfNoneMatch *string + + mock.onPutBlob = func(param *PutBlobInput) { + capturedIfMatch = param.IfMatch + capturedIfNoneMatch = param.IfNoneMatch + } + + // Test IfNoneMatch is captured + ifNoneMatch := "*" + _, err := mock.PutBlob(&PutBlobInput{ + Key: "test1", + Body: bytes.NewReader([]byte("data")), + IfNoneMatch: &ifNoneMatch, + }) + t.Assert(err, IsNil) + t.Assert(capturedIfNoneMatch, NotNil) + t.Assert(*capturedIfNoneMatch, Equals, "*") + t.Assert(capturedIfMatch, IsNil) + + // Reset + capturedIfMatch = nil + capturedIfNoneMatch = nil + + // Test IfMatch is captured + existingETag := mock.objects["test1"].etag + _, err = mock.PutBlob(&PutBlobInput{ + Key: "test1", + Body: bytes.NewReader([]byte("updated")), + IfMatch: &existingETag, + }) + t.Assert(err, IsNil) + t.Assert(capturedIfMatch, NotNil) + t.Assert(*capturedIfMatch, Equals, existingETag) + t.Assert(capturedIfNoneMatch, IsNil) +} + +func (s *BackendTest) TestGetBlobHookInspection(t *C) { + mock := newMockConditionalBackend() + + existingETag := "\"test-etag\"" + mock.objects["test-key"] = &mockStoredObject{ + data: []byte("test data"), + etag: existingETag, + } + + var capturedIfMatch *string + mock.onGetBlob = func(param *GetBlobInput) { + capturedIfMatch = param.IfMatch + } + + // Test without IfMatch + resp, err := mock.GetBlob(&GetBlobInput{Key: "test-key"}) + t.Assert(err, IsNil) + resp.Body.Close() + t.Assert(capturedIfMatch, IsNil) + + // Test with IfMatch + resp, err = mock.GetBlob(&GetBlobInput{ + Key: "test-key", + IfMatch: &existingETag, + }) + t.Assert(err, IsNil) + resp.Body.Close() + t.Assert(capturedIfMatch, NotNil) + t.Assert(*capturedIfMatch, Equals, existingETag) +} diff --git a/core/symlinks.go b/core/symlinks.go index 405ff2e0..1bc01246 100644 --- a/core/symlinks.go +++ b/core/symlinks.go @@ -17,9 +17,11 @@ package core import ( "bytes" "encoding/json" + "fmt" "io" "strings" "sync" + "syscall" "time" ) @@ -181,15 +183,25 @@ func SaveSymlinksFile(cloud StorageBackend, dirKey string, symlinksFileName stri return "", err } - // Note: S3 doesn't support conditional PUT with If-Match directly in PutBlob - // For true atomicity, we would need to use S3's conditional writes feature - // or implement a read-modify-write with retry logic - // For now, we do a simple PUT - resp, err := cloud.PutBlob(&PutBlobInput{ + // Use conditional writes for optimistic locking (S3 feature since 2024): + // - If expectedETag is empty, use If-None-Match: "*" to only create if file doesn't exist + // - If expectedETag is provided, use If-Match to only update if ETag matches (optimistic locking) + putInput := &PutBlobInput{ Key: key, Body: bytes.NewReader(content), Size: PUInt64(uint64(len(content))), - }) + } + + if expectedETag == "" { + // Creating a new file - use If-None-Match to prevent overwriting + ifNoneMatch := "*" + putInput.IfNoneMatch = &ifNoneMatch + } else { + // Updating existing file - use If-Match for optimistic locking + putInput.IfMatch = &expectedETag + } + + resp, err := cloud.PutBlob(putInput) if err != nil { return "", err @@ -203,6 +215,105 @@ func SaveSymlinksFile(cloud StorageBackend, dirKey string, symlinksFileName stri return newETag, nil } +// SymlinksMergeFunc is called when a conflict is detected during save. +// It receives the current data from cloud storage and must return the merged data to save. +// The function should merge the caller's pending changes into currentData. +type SymlinksMergeFunc func(currentData *SymlinksFileData) (*SymlinksFileData, error) + +// isPreconditionFailed checks if an error is a precondition failed error (412) +func isPreconditionFailed(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return strings.Contains(errStr, "PreconditionFailed") || + strings.Contains(errStr, "412") || + strings.Contains(errStr, "Precondition Failed") || + strings.Contains(errStr, "conditional request failed") +} + +// SaveSymlinksFileWithRetry saves the .symlinks file with automatic retry on conflict. +// Uses exponential backoff and calls the merge function to resolve conflicts. +// Parameters: +// - cloud: the storage backend +// - dirKey: directory key (prefix) +// - symlinksFileName: name of the symlinks file (e.g., ".symlinks") +// - data: initial data to save +// - expectedETag: current known ETag (empty for new files) +// - mergeFn: function to merge changes on conflict (receives current cloud data) +// - maxRetries: maximum number of retry attempts (0 for no retries) +// +// Returns the new ETag and any error. +func SaveSymlinksFileWithRetry( + cloud StorageBackend, + dirKey string, + symlinksFileName string, + data *SymlinksFileData, + expectedETag string, + mergeFn SymlinksMergeFunc, + maxRetries int, +) (string, error) { + const ( + initialBackoff = 50 * time.Millisecond + maxBackoff = 2 * time.Second + backoffFactor = 2.0 + ) + + currentData := data + currentETag := expectedETag + backoff := initialBackoff + + for attempt := 0; attempt <= maxRetries; attempt++ { + // Try to save + newETag, err := SaveSymlinksFile(cloud, dirKey, symlinksFileName, currentData, currentETag) + if err == nil { + return newETag, nil + } + + // If not a precondition failure, return the error immediately + if !isPreconditionFailed(err) { + return "", err + } + + // Precondition failed - conflict detected + if attempt >= maxRetries { + return "", fmt.Errorf("symlinks file conflict: max retries (%d) exceeded: %w", maxRetries, err) + } + + // Wait with exponential backoff before retrying + time.Sleep(backoff) + backoff = time.Duration(float64(backoff) * backoffFactor) + if backoff > maxBackoff { + backoff = maxBackoff + } + + // Re-read the current state from cloud storage + cloudData, cloudETag, loadErr := LoadSymlinksFile(cloud, dirKey, symlinksFileName) + if loadErr != nil { + // If file was deleted, treat as empty + if isNotExist(loadErr) { + cloudData = NewSymlinksFileData() + cloudETag = "" + } else { + return "", fmt.Errorf("failed to reload symlinks file during retry: %w", loadErr) + } + } + + // Call merge function to combine our changes with the cloud state + mergedData, mergeErr := mergeFn(cloudData) + if mergeErr != nil { + return "", fmt.Errorf("merge function failed: %w", mergeErr) + } + + // Update for next attempt + currentData = mergedData + currentETag = cloudETag + } + + // Should not reach here + return "", fmt.Errorf("symlinks file save failed unexpectedly") +} + // DeleteSymlinksFile removes the .symlinks file from cloud storage func DeleteSymlinksFile(cloud StorageBackend, dirKey string, symlinksFileName string) error { key := getSymlinksFilePath(dirKey, symlinksFileName) @@ -218,9 +329,14 @@ func isNotExist(err error) bool { if err == nil { return false } + // Check for syscall error + if err == syscall.ENOENT { + return true + } errStr := err.Error() return strings.Contains(errStr, "NoSuchKey") || strings.Contains(errStr, "NotFound") || strings.Contains(errStr, "404") || - strings.Contains(errStr, "does not exist") + strings.Contains(errStr, "does not exist") || + strings.Contains(errStr, "no such file or directory") } diff --git a/core/symlinks_test.go b/core/symlinks_test.go index 5578f092..f54fe2c9 100644 --- a/core/symlinks_test.go +++ b/core/symlinks_test.go @@ -15,7 +15,7 @@ package core import ( - "testing" + "fmt" . "gopkg.in/check.v1" ) @@ -24,9 +24,9 @@ type SymlinksTest struct{} var _ = Suite(&SymlinksTest{}) -func TestSymlinks(t *testing.T) { - TestingT(t) -} +// ============================================================================ +// Tests for SymlinksFileData struct operations +// ============================================================================ func (s *SymlinksTest) TestNewSymlinksFileData(t *C) { data := NewSymlinksFileData() @@ -194,3 +194,387 @@ func (s *SymlinksTest) TestSymlinkTargetWithSpecialCharacters(t *C) { t.Assert(ok, Equals, true) t.Assert(target, Equals, "../.hidden-folder/.hidden-file") } + +// ============================================================================ +// Tests for Load/Save/Delete symlinks file operations with cloud storage +// These tests use mockConditionalBackend from backend_test.go +// ============================================================================ + +func (s *SymlinksTest) TestSaveSymlinksFileCreateNew(t *C) { + mock := newMockConditionalBackend() + + data := NewSymlinksFileData() + data.AddSymlink("link1", "../target1") + + // Save new file (no expectedETag) + etag, err := SaveSymlinksFile(mock, "testdir", ".symlinks", data, "") + t.Assert(err, IsNil) + t.Assert(etag != "", Equals, true) + + // Verify it was saved + _, exists := mock.objects["testdir/.symlinks"] + t.Assert(exists, Equals, true) +} + +func (s *SymlinksTest) TestSaveSymlinksFileCreateNewPreventsOverwrite(t *C) { + mock := newMockConditionalBackend() + + // Pre-create an existing file + mock.objects["testdir/.symlinks"] = &mockStoredObject{ + data: []byte(`{"version":1,"symlinks":{}}`), + etag: "\"existing\"", + } + + data := NewSymlinksFileData() + data.AddSymlink("link1", "../target1") + + // Try to create new file (no expectedETag) - should fail because file exists + _, err := SaveSymlinksFile(mock, "testdir", ".symlinks", data, "") + t.Assert(err, NotNil) + t.Assert(err.Error(), Matches, ".*PreconditionFailed.*") +} + +func (s *SymlinksTest) TestSaveSymlinksFileUpdateWithCorrectETag(t *C) { + mock := newMockConditionalBackend() + + // Pre-create an existing file + existingETag := "\"existing-etag\"" + mock.objects["testdir/.symlinks"] = &mockStoredObject{ + data: []byte(`{"version":1,"symlinks":{"old":"../old-target"}}`), + etag: existingETag, + } + + data := NewSymlinksFileData() + data.AddSymlink("link1", "../target1") + + // Update with correct ETag - should succeed + newETag, err := SaveSymlinksFile(mock, "testdir", ".symlinks", data, existingETag) + t.Assert(err, IsNil) + t.Assert(newETag != "", Equals, true) + t.Assert(newETag != existingETag, Equals, true) +} + +func (s *SymlinksTest) TestSaveSymlinksFileUpdateWithWrongETag(t *C) { + mock := newMockConditionalBackend() + + // Pre-create an existing file + mock.objects["testdir/.symlinks"] = &mockStoredObject{ + data: []byte(`{"version":1,"symlinks":{"old":"../old-target"}}`), + etag: "\"actual-etag\"", + } + + data := NewSymlinksFileData() + data.AddSymlink("link1", "../target1") + + // Update with wrong ETag - should fail (optimistic locking) + _, err := SaveSymlinksFile(mock, "testdir", ".symlinks", data, "\"wrong-etag\"") + t.Assert(err, NotNil) + t.Assert(err.Error(), Matches, ".*PreconditionFailed.*") +} + +func (s *SymlinksTest) TestLoadSymlinksFile(t *C) { + mock := newMockConditionalBackend() + + // Pre-create a file + existingETag := "\"test-etag\"" + mock.objects["testdir/.symlinks"] = &mockStoredObject{ + data: []byte(`{"version":1,"symlinks":{"link1":{"target":"../target1"}}}`), + etag: existingETag, + } + + data, etag, err := LoadSymlinksFile(mock, "testdir", ".symlinks") + t.Assert(err, IsNil) + t.Assert(etag, Equals, existingETag) + t.Assert(data.HasSymlink("link1"), Equals, true) + + target, ok := data.GetSymlink("link1") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "../target1") +} + +func (s *SymlinksTest) TestLoadSymlinksFileNotFound(t *C) { + mock := newMockConditionalBackend() + + data, etag, err := LoadSymlinksFile(mock, "testdir", ".symlinks") + t.Assert(err, IsNil) + t.Assert(etag, Equals, "") + t.Assert(data.IsEmpty(), Equals, true) +} + +func (s *SymlinksTest) TestDeleteSymlinksFile(t *C) { + mock := newMockConditionalBackend() + + // Pre-create a file + mock.objects["testdir/.symlinks"] = &mockStoredObject{ + data: []byte(`{"version":1,"symlinks":{}}`), + etag: "\"test\"", + } + + err := DeleteSymlinksFile(mock, "testdir", ".symlinks") + t.Assert(err, IsNil) + + _, exists := mock.objects["testdir/.symlinks"] + t.Assert(exists, Equals, false) +} + +func (s *SymlinksTest) TestSaveEmptySymlinksFileDeletesExisting(t *C) { + mock := newMockConditionalBackend() + + // Pre-create an existing file + mock.objects["testdir/.symlinks"] = &mockStoredObject{ + data: []byte(`{"version":1,"symlinks":{"link1":{"target":"../target1"}}}`), + etag: "\"existing\"", + } + + // Save empty data with existing ETag should delete the file + emptyData := NewSymlinksFileData() + _, err := SaveSymlinksFile(mock, "testdir", ".symlinks", emptyData, "\"existing\"") + t.Assert(err, IsNil) + + _, exists := mock.objects["testdir/.symlinks"] + t.Assert(exists, Equals, false) +} + +func (s *SymlinksTest) TestConcurrentSymlinkCreation(t *C) { + mock := newMockConditionalBackend() + + // Simulate two concurrent creations - first one wins + data1 := NewSymlinksFileData() + data1.AddSymlink("link1", "../target1") + + data2 := NewSymlinksFileData() + data2.AddSymlink("link2", "../target2") + + // First creation succeeds + etag1, err := SaveSymlinksFile(mock, "testdir", ".symlinks", data1, "") + t.Assert(err, IsNil) + t.Assert(etag1 != "", Equals, true) + + // Second creation fails (file already exists) + _, err = SaveSymlinksFile(mock, "testdir", ".symlinks", data2, "") + t.Assert(err, NotNil) + + // Proper way: load, merge, save with ETag + existingData, etag, err := LoadSymlinksFile(mock, "testdir", ".symlinks") + t.Assert(err, IsNil) + t.Assert(etag, Equals, etag1) + + existingData.AddSymlink("link2", "../target2") + newETag, err := SaveSymlinksFile(mock, "testdir", ".symlinks", existingData, etag) + t.Assert(err, IsNil) + t.Assert(newETag != etag, Equals, true) + + // Verify both symlinks exist + finalData, _, err := LoadSymlinksFile(mock, "testdir", ".symlinks") + t.Assert(err, IsNil) + t.Assert(finalData.HasSymlink("link1"), Equals, true) + t.Assert(finalData.HasSymlink("link2"), Equals, true) +} + +// ============================================================================ +// Tests for verifying SaveSymlinksFile uses correct conditional write headers +// ============================================================================ + +func (s *SymlinksTest) TestSaveSymlinksFileUsesIfNoneMatchForNewFile(t *C) { + mock := newMockConditionalBackend() + + var capturedIfNoneMatch *string + mock.onPutBlob = func(param *PutBlobInput) { + capturedIfNoneMatch = param.IfNoneMatch + } + + data := NewSymlinksFileData() + data.AddSymlink("link1", "../target1") + + // Save new file - should use If-None-Match: "*" + _, err := SaveSymlinksFile(mock, "testdir", ".symlinks", data, "") + t.Assert(err, IsNil) + t.Assert(capturedIfNoneMatch, NotNil) + t.Assert(*capturedIfNoneMatch, Equals, "*") +} + +func (s *SymlinksTest) TestSaveSymlinksFileUsesIfMatchForUpdate(t *C) { + mock := newMockConditionalBackend() + + // Pre-create existing file + existingETag := "\"existing-etag\"" + mock.objects["testdir/.symlinks"] = &mockStoredObject{ + data: []byte(`{"version":1,"symlinks":{}}`), + etag: existingETag, + } + + var capturedIfMatch *string + mock.onPutBlob = func(param *PutBlobInput) { + capturedIfMatch = param.IfMatch + } + + data := NewSymlinksFileData() + data.AddSymlink("link1", "../target1") + + // Update with ETag - should use If-Match + _, err := SaveSymlinksFile(mock, "testdir", ".symlinks", data, existingETag) + t.Assert(err, IsNil) + t.Assert(capturedIfMatch, NotNil) + t.Assert(*capturedIfMatch, Equals, existingETag) +} + +// ============================================================================ +// Tests for SaveSymlinksFileWithRetry (exponential backoff) +// ============================================================================ + +func (s *SymlinksTest) TestSaveWithRetrySucceedsOnFirstAttempt(t *C) { + mock := newMockConditionalBackend() + + data := NewSymlinksFileData() + data.AddSymlink("link1", "../target1") + + mergeFn := func(current *SymlinksFileData) (*SymlinksFileData, error) { + t.Fatal("Merge function should not be called on first success") + return nil, nil + } + + newETag, err := SaveSymlinksFileWithRetry(mock, "testdir", ".symlinks", data, "", mergeFn, 3) + t.Assert(err, IsNil) + t.Assert(newETag, Not(Equals), "") +} + +func (s *SymlinksTest) TestSaveWithRetryRetriesOnConflict(t *C) { + mock := newMockConditionalBackend() + + // Pre-create a file to cause initial conflict + mock.objects["testdir/.symlinks"] = &mockStoredObject{ + data: []byte(`{"version":1,"symlinks":{"existing":{"target":"../old","mtime":1}}}`), + etag: "\"etag-v1\"", + } + + data := NewSymlinksFileData() + data.AddSymlink("newlink", "../newtarget") + + mergeCallCount := 0 + mergeFn := func(current *SymlinksFileData) (*SymlinksFileData, error) { + mergeCallCount++ + // Merge: keep existing symlinks and add our new one + current.AddSymlink("newlink", "../newtarget") + return current, nil + } + + // Try to create (If-None-Match: "*") - will fail, then retry with merge + newETag, err := SaveSymlinksFileWithRetry(mock, "testdir", ".symlinks", data, "", mergeFn, 3) + t.Assert(err, IsNil) + t.Assert(newETag, Not(Equals), "") + t.Assert(mergeCallCount, Equals, 1) + + // Verify merged result contains both symlinks + obj := mock.objects["testdir/.symlinks"] + parsed, _ := ParseSymlinksFile(obj.data) + t.Assert(parsed.HasSymlink("existing"), Equals, true) + t.Assert(parsed.HasSymlink("newlink"), Equals, true) +} + +func (s *SymlinksTest) TestSaveWithRetryExceedsMaxRetries(t *C) { + mock := newMockConditionalBackend() + + // Create a mock that always fails with precondition error + failingMock := &alwaysConflictBackend{mock} + + data := NewSymlinksFileData() + data.AddSymlink("link1", "../target1") + + mergeCallCount := 0 + mergeFn := func(current *SymlinksFileData) (*SymlinksFileData, error) { + mergeCallCount++ + return data, nil + } + + _, err := SaveSymlinksFileWithRetry(failingMock, "testdir", ".symlinks", data, "", mergeFn, 2) + t.Assert(err, NotNil) + t.Assert(err.Error(), Matches, ".*max retries.*exceeded.*") + t.Assert(mergeCallCount, Equals, 2) // Should have tried merge twice +} + +func (s *SymlinksTest) TestSaveWithRetryMergeFunctionError(t *C) { + mock := newMockConditionalBackend() + + // Pre-create a file to cause conflict + mock.objects["testdir/.symlinks"] = &mockStoredObject{ + data: []byte(`{"version":1,"symlinks":{}}`), + etag: "\"etag-v1\"", + } + + data := NewSymlinksFileData() + data.AddSymlink("link1", "../target1") + + mergeFn := func(current *SymlinksFileData) (*SymlinksFileData, error) { + return nil, fmt.Errorf("merge conflict: cannot resolve") + } + + _, err := SaveSymlinksFileWithRetry(mock, "testdir", ".symlinks", data, "", mergeFn, 3) + t.Assert(err, NotNil) + t.Assert(err.Error(), Matches, ".*merge function failed.*") +} + +func (s *SymlinksTest) TestSaveWithRetryNoRetriesOnOtherErrors(t *C) { + mock := newMockConditionalBackend() + + // Create a mock that returns a non-precondition error + errorMock := &errorBackend{mock, fmt.Errorf("network error: connection refused")} + + data := NewSymlinksFileData() + data.AddSymlink("link1", "../target1") + + mergeCallCount := 0 + mergeFn := func(current *SymlinksFileData) (*SymlinksFileData, error) { + mergeCallCount++ + return data, nil + } + + _, err := SaveSymlinksFileWithRetry(errorMock, "testdir", ".symlinks", data, "", mergeFn, 3) + t.Assert(err, NotNil) + t.Assert(err.Error(), Matches, ".*network error.*") + t.Assert(mergeCallCount, Equals, 0) // Should not have tried merge +} + +func (s *SymlinksTest) TestSaveWithRetryZeroMaxRetries(t *C) { + mock := newMockConditionalBackend() + + // Pre-create a file to cause conflict + mock.objects["testdir/.symlinks"] = &mockStoredObject{ + data: []byte(`{"version":1,"symlinks":{}}`), + etag: "\"etag-v1\"", + } + + data := NewSymlinksFileData() + data.AddSymlink("link1", "../target1") + + mergeCallCount := 0 + mergeFn := func(current *SymlinksFileData) (*SymlinksFileData, error) { + mergeCallCount++ + return data, nil + } + + // With maxRetries=0, should fail immediately on conflict + _, err := SaveSymlinksFileWithRetry(mock, "testdir", ".symlinks", data, "", mergeFn, 0) + t.Assert(err, NotNil) + t.Assert(err.Error(), Matches, ".*max retries.*exceeded.*") + t.Assert(mergeCallCount, Equals, 0) +} + +// alwaysConflictBackend wraps a backend and always returns precondition failed on PutBlob +type alwaysConflictBackend struct { + *mockConditionalBackend +} + +func (m *alwaysConflictBackend) PutBlob(param *PutBlobInput) (*PutBlobOutput, error) { + // Always return precondition failed + return nil, fmt.Errorf("PreconditionFailed: simulated conflict") +} + +// errorBackend wraps a backend and returns a specific error on PutBlob +type errorBackend struct { + *mockConditionalBackend + err error +} + +func (m *errorBackend) PutBlob(param *PutBlobInput) (*PutBlobOutput, error) { + return nil, m.err +} diff --git a/s3ext/models/apis/s3/2006-03-01/api-2.json b/s3ext/models/apis/s3/2006-03-01/api-2.json index c21a53af..15228f13 100644 --- a/s3ext/models/apis/s3/2006-03-01/api-2.json +++ b/s3ext/models/apis/s3/2006-03-01/api-2.json @@ -1451,6 +1451,16 @@ "shape":"AccountId", "location":"header", "locationName":"x-amz-expected-bucket-owner" + }, + "IfMatch":{ + "shape":"IfMatch", + "location":"header", + "locationName":"If-Match" + }, + "IfNoneMatch":{ + "shape":"IfNoneMatch", + "location":"header", + "locationName":"If-None-Match" } }, "payload":"MultipartUpload" @@ -6479,6 +6489,16 @@ "shape":"AccountId", "location":"header", "locationName":"x-amz-expected-bucket-owner" + }, + "IfMatch":{ + "shape":"IfMatch", + "location":"header", + "locationName":"If-Match" + }, + "IfNoneMatch":{ + "shape":"IfNoneMatch", + "location":"header", + "locationName":"If-None-Match" } }, "payload":"Body" diff --git a/s3ext/service/s3/api.go b/s3ext/service/s3/api.go index ff3a1a49..3c43ceaf 100644 --- a/s3ext/service/s3/api.go +++ b/s3ext/service/s3/api.go @@ -12200,6 +12200,10 @@ type CompleteMultipartUploadInput struct { // error. ExpectedBucketOwner *string `location:"header" locationName:"x-amz-expected-bucket-owner" type:"string"` + IfMatch *string `location:"header" locationName:"If-Match" type:"string"` + + IfNoneMatch *string `location:"header" locationName:"If-None-Match" type:"string"` + // Object key for which the multipart upload was initiated. // // Key is a required field @@ -12275,6 +12279,18 @@ func (s *CompleteMultipartUploadInput) SetExpectedBucketOwner(v string) *Complet return s } +// SetIfMatch sets the IfMatch field's value. +func (s *CompleteMultipartUploadInput) SetIfMatch(v string) *CompleteMultipartUploadInput { + s.IfMatch = &v + return s +} + +// SetIfNoneMatch sets the IfNoneMatch field's value. +func (s *CompleteMultipartUploadInput) SetIfNoneMatch(v string) *CompleteMultipartUploadInput { + s.IfNoneMatch = &v + return s +} + // SetKey sets the Key field's value. func (s *CompleteMultipartUploadInput) SetKey(v string) *CompleteMultipartUploadInput { s.Key = &v @@ -30628,6 +30644,10 @@ type PutObjectInput struct { // This action is not supported by Amazon S3 on Outposts. GrantWriteACP *string `location:"header" locationName:"x-amz-grant-write-acp" type:"string"` + IfMatch *string `location:"header" locationName:"If-Match" type:"string"` + + IfNoneMatch *string `location:"header" locationName:"If-None-Match" type:"string"` + // Object key for which the PUT action was initiated. // // Key is a required field @@ -30864,6 +30884,18 @@ func (s *PutObjectInput) SetGrantWriteACP(v string) *PutObjectInput { return s } +// SetIfMatch sets the IfMatch field's value. +func (s *PutObjectInput) SetIfMatch(v string) *PutObjectInput { + s.IfMatch = &v + return s +} + +// SetIfNoneMatch sets the IfNoneMatch field's value. +func (s *PutObjectInput) SetIfNoneMatch(v string) *PutObjectInput { + s.IfNoneMatch = &v + return s +} + // SetKey sets the Key field's value. func (s *PutObjectInput) SetKey(v string) *PutObjectInput { s.Key = &v diff --git a/s3ext/service/s3/s3manager/upload_input.go b/s3ext/service/s3/s3manager/upload_input.go index fb885371..ccd39af0 100644 --- a/s3ext/service/s3/s3manager/upload_input.go +++ b/s3ext/service/s3/s3manager/upload_input.go @@ -111,6 +111,10 @@ type UploadInput struct { // This action is not supported by Amazon S3 on Outposts. GrantWriteACP *string `location:"header" locationName:"x-amz-grant-write-acp" type:"string"` + IfMatch *string `location:"header" locationName:"If-Match" type:"string"` + + IfNoneMatch *string `location:"header" locationName:"If-None-Match" type:"string"` + // Object key for which the PUT action was initiated. // // Key is a required field From 439ebaf9ed50381ed89574dffca0f76017e9bccd Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 27 Jan 2026 12:10:43 +0100 Subject: [PATCH 03/39] restrict symlinks to s3-compatible systems --- core/backend.go | 6 ++++++ core/cfg/flags.go | 3 ++- core/goofys.go | 5 +++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/core/backend.go b/core/backend.go index dcb78321..21d82cfc 100644 --- a/core/backend.go +++ b/core/backend.go @@ -32,6 +32,12 @@ type Capabilities struct { Name string } +// IsS3Compatible returns true if the backend is S3-compatible (supports conditional writes). +// This includes AWS S3, MinIO, GCS (via S3 API), and other S3-compatible storage services. +func (c *Capabilities) IsS3Compatible() bool { + return c.Name == "s3" || c.Name == "gcs" +} + type HeadBlobInput struct { Key string } diff --git a/core/cfg/flags.go b/core/cfg/flags.go index d943c674..045dfa18 100644 --- a/core/cfg/flags.go +++ b/core/cfg/flags.go @@ -568,7 +568,8 @@ MISC OPTIONS: cli.BoolFlag{ Name: "enable-symlinks-file", Usage: "Store symlinks in a hidden .symlinks file per directory instead of object metadata." + - " Useful for S3 backends that don't return UserMetadata in listings (e.g., AWS S3).", + " Only supported with S3-compatible backends (AWS S3, MinIO, GCS)." + + " Useful for S3 backends that don't return UserMetadata in listings.", }, cli.StringFlag{ diff --git a/core/goofys.go b/core/goofys.go index 2f743cb0..2bc2ea28 100644 --- a/core/goofys.go +++ b/core/goofys.go @@ -323,6 +323,11 @@ func newGoofys(ctx context.Context, bucket string, flags *cfg.FlagStorage, return nil, fmt.Errorf("Unable to setup backend: %v", err) } + // Validate that symlinks file feature is only used with S3-compatible backends + if flags.EnableSymlinksFile && !cloud.Capabilities().IsS3Compatible() { + return nil, fmt.Errorf("--enable-symlinks-file is only supported with S3-compatible backends (s3, gcs, minio). Current backend: %v", cloud.Capabilities().Name) + } + randomObjectName := prefix + (RandStringBytesMaskImprSrc(32)) err = cloud.Init(randomObjectName) if err != nil { From e3b3931d85bcc2b837760814bb451f5577ab3866 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Thu, 29 Jan 2026 11:21:24 +0100 Subject: [PATCH 04/39] rename .symlinks to .geesefs_symlinks --- core/backend.go | 9 ++--- core/backend_s3.go | 4 +++ core/cfg/flags.go | 12 ++----- core/dir.go | 50 +++++++++++++++++++-------- core/symlinks.go | 62 ++++++++++++++++++++++++++++++++++ core/symlinks_test.go | 78 +++++++++++++++++++++---------------------- 6 files changed, 149 insertions(+), 66 deletions(-) diff --git a/core/backend.go b/core/backend.go index 21d82cfc..cf398513 100644 --- a/core/backend.go +++ b/core/backend.go @@ -122,10 +122,11 @@ type CopyBlobOutput struct { } type GetBlobInput struct { - Key string - Start uint64 - Count uint64 - IfMatch *string + Key string + Start uint64 + Count uint64 + IfMatch *string + IfNoneMatch *string // For conditional GET - returns 304 if ETag matches } type GetBlobOutput struct { diff --git a/core/backend_s3.go b/core/backend_s3.go index f0221917..9b3b3ed8 100644 --- a/core/backend_s3.go +++ b/core/backend_s3.go @@ -930,6 +930,10 @@ func (s *S3Backend) GetBlob(param *GetBlobInput) (*GetBlobOutput, error) { get.IfMatch = param.IfMatch } + if param.IfNoneMatch != nil { + get.IfNoneMatch = param.IfNoneMatch + } + req, resp := s.GetObjectRequest(&get) err := req.Send() if err != nil { diff --git a/core/cfg/flags.go b/core/cfg/flags.go index 045dfa18..457e4cf6 100644 --- a/core/cfg/flags.go +++ b/core/cfg/flags.go @@ -558,13 +558,6 @@ MISC OPTIONS: Usage: "File modification time (UNIX time) metadata attribute name", }, - cli.StringFlag{ - Name: "symlink-attr", - Value: "--symlink-target", - Usage: "Symbolic link target metadata attribute name." + - " Only works correctly if your S3 returns UserMetadata in listings", - }, - cli.BoolFlag{ Name: "enable-symlinks-file", Usage: "Store symlinks in a hidden .symlinks file per directory instead of object metadata." + @@ -574,7 +567,7 @@ MISC OPTIONS: cli.StringFlag{ Name: "symlinks-file", - Value: ".symlinks", + Value: ".geesefs_symlinks", Usage: "Name of the hidden file storing symlinks metadata when --enable-symlinks-file is used.", }, @@ -879,7 +872,6 @@ func PopulateFlags(c *cli.Context) (ret *FlagStorage) { FileModeAttr: c.String("mode-attr"), RdevAttr: c.String("rdev-attr"), MtimeAttr: c.String("mtime-attr"), - SymlinkAttr: c.String("symlink-attr"), SymlinksFile: c.String("symlinks-file"), EnableSymlinksFile: c.Bool("enable-symlinks-file"), RefreshAttr: c.String("refresh-attr"), @@ -1086,7 +1078,7 @@ func DefaultFlags() *FlagStorage { RdevAttr: "rdev", MtimeAttr: "mtime", SymlinkAttr: "--symlink-target", - SymlinksFile: ".symlinks", + SymlinksFile: ".geesefs_symlinks", RefreshAttr: ".invalidate", StatCacheTTL: 30 * time.Second, HTTPTimeout: 30 * time.Second, diff --git a/core/dir.go b/core/dir.go index 53dbf607..a900b1c3 100644 --- a/core/dir.go +++ b/core/dir.go @@ -62,9 +62,9 @@ type DirInodeData struct { handles []*DirHandle // Symlinks file cache (used when EnableSymlinksFile is true) - symlinksCache *SymlinksFileData - symlinksCacheETag string - symlinksCacheTime time.Time + symlinksCache *SymlinksFileData + symlinksCacheETag string + symlinksCacheTime time.Time } // Returns the position of first char < '/' in `inp` after prefixLen + any continued '/' characters. @@ -1166,6 +1166,13 @@ func (parent *Inode) Unlink(name string) (err error) { s3Log.Warnf("Failed to update symlinks file for unlink %v: %v", name, err) // Continue anyway, the symlink entry will be orphaned but the file will be deleted } + // Virtual symlink: just remove from cache, no S3 object to delete + inode.mu.Lock() + inode.resetCache() + inode.SetCacheState(ST_DEAD) + parent.removeChildUnlocked(inode) + inode.mu.Unlock() + return nil } } @@ -1428,7 +1435,6 @@ func (parent *Inode) CreateSymlink( if err := parent.updateSymlinksFile(name, target, false); err != nil { return nil, err } - // Don't mark metadata as dirty since we're using the symlinks file inode.userMetadataDirty = 0 } else { inode.userMetadataDirty = 2 @@ -1448,8 +1454,13 @@ func (parent *Inode) CreateSymlink( inode.Ref() // another ref is for being in Children fs.insertInode(parent, inode) - inode.SetCacheState(ST_CREATED) - fs.WakeupFlusher() + // If using symlinks file, symlink is purely virtual (no S3 object) + if fs.flags.EnableSymlinksFile { + inode.SetCacheState(ST_CACHED) + } else { + inode.SetCacheState(ST_CREATED) + fs.WakeupFlusher() + } parent.touch() @@ -1512,12 +1523,6 @@ func (parent *Inode) loadSymlinksCache() error { return nil } - // Check if cache is still valid (within stat-cache-ttl) - if parent.dir.symlinksCache != nil && - time.Since(parent.dir.symlinksCacheTime) < parent.fs.flags.StatCacheTTL { - return nil - } - cloud, dirKey := parent.cloud() if cloud == nil { return syscall.ESTALE @@ -1526,11 +1531,23 @@ func (parent *Inode) loadSymlinksCache() error { dirKey = strings.TrimSuffix(dirKey, "/") symlinksFileName := parent.fs.flags.SymlinksFile - data, etag, err := LoadSymlinksFile(cloud, dirKey, symlinksFileName) + // Use conditional GET with cached ETag to check if file has changed + cachedETag := parent.dir.symlinksCacheETag + data, etag, err := LoadSymlinksFileConditional(cloud, dirKey, symlinksFileName, cachedETag) if err != nil { return err } + // If data is nil, the file hasn't changed (304 Not Modified) + if data == nil { + s3Log.Debugf("loadSymlinksCache: file unchanged (304), dir=%v", parent.FullName()) + return nil + } + + s3Log.Debugf("loadSymlinksCache: loaded dir=%v, dataEmpty=%v, etag=%q, cachedEmpty=%v", + parent.FullName(), data.IsEmpty(), etag, + parent.dir.symlinksCache == nil || parent.dir.symlinksCache.IsEmpty()) + parent.dir.symlinksCache = data parent.dir.symlinksCacheETag = etag parent.dir.symlinksCacheTime = time.Now() @@ -2160,6 +2177,13 @@ func (parent *Inode) LookUp(name string, doSlurp bool) (*Inode, error) { prefixLen++ } parent.mu.Lock() + // Load symlinks cache before inserting the inode + if parent.fs.flags.EnableSymlinksFile { + if err := parent.loadSymlinksCache(); err != nil { + s3Log.Warnf("Failed to load symlinks cache for %v: %v", parent.FullName(), err) + // Continue anyway, symlinks just won't work + } + } skipListing := parent.fs.completeInflightListing(myList) if skipListing == nil || !skipListing[*blob.Key] { parent.insertSubTree((*blob.Key)[prefixLen:], blob, dirs) diff --git a/core/symlinks.go b/core/symlinks.go index 1bc01246..71d1cd81 100644 --- a/core/symlinks.go +++ b/core/symlinks.go @@ -158,6 +158,68 @@ func LoadSymlinksFile(cloud StorageBackend, dirKey string, symlinksFileName stri return parsed, etag, nil } +// LoadSymlinksFileConditional loads the .symlinks file only if it has changed (different ETag). +// If cachedETag matches the current file's ETag, returns (nil, cachedETag, nil) to indicate no change. +// If the file has changed or cachedETag is empty, returns the new data and ETag. +// Returns the parsed data (nil if unchanged), the current ETag, and any error. +func LoadSymlinksFileConditional(cloud StorageBackend, dirKey string, symlinksFileName string, cachedETag string) (*SymlinksFileData, string, error) { + key := getSymlinksFilePath(dirKey, symlinksFileName) + + input := &GetBlobInput{ + Key: key, + Start: 0, + Count: 0, // Read entire file + } + + // If we have a cached ETag, use conditional GET + if cachedETag != "" { + input.IfNoneMatch = &cachedETag + } + + resp, err := cloud.GetBlob(input) + if err != nil { + // Check for 304 Not Modified + if isNotModified(err) { + return nil, cachedETag, nil // No change, return nil data + } + // If file doesn't exist, return empty data + if isNotExist(err) { + return NewSymlinksFileData(), "", nil + } + return nil, "", err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + + parsed, err := ParseSymlinksFile(data) + if err != nil { + return nil, "", err + } + + etag := "" + if resp.ETag != nil { + etag = *resp.ETag + } + + return parsed, etag, nil +} + +// isNotModified checks if an error indicates a 304 Not Modified response +func isNotModified(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return strings.Contains(errStr, "304") || + strings.Contains(errStr, "NotModified") || + strings.Contains(errStr, "Not Modified") +} + + // SaveSymlinksFile saves the .symlinks file to cloud storage with conditional write // If expectedETag is non-empty, uses conditional PUT to avoid race conditions // Returns the new ETag and any error diff --git a/core/symlinks_test.go b/core/symlinks_test.go index f54fe2c9..3e7817e8 100644 --- a/core/symlinks_test.go +++ b/core/symlinks_test.go @@ -99,10 +99,10 @@ func (s *SymlinksTest) TestParseInvalidJSON(t *C) { } func (s *SymlinksTest) TestGetSymlinksFilePath(t *C) { - t.Assert(getSymlinksFilePath("", ".symlinks"), Equals, ".symlinks") - t.Assert(getSymlinksFilePath("dir", ".symlinks"), Equals, "dir/.symlinks") - t.Assert(getSymlinksFilePath("dir/", ".symlinks"), Equals, "dir/.symlinks") - t.Assert(getSymlinksFilePath("path/to/dir", ".symlinks"), Equals, "path/to/dir/.symlinks") + t.Assert(getSymlinksFilePath("", ".geesefs_symlinks"), Equals, ".geesefs_symlinks") + t.Assert(getSymlinksFilePath("dir", ".geesefs_symlinks"), Equals, "dir/.geesefs_symlinks") + t.Assert(getSymlinksFilePath("dir/", ".geesefs_symlinks"), Equals, "dir/.geesefs_symlinks") + t.Assert(getSymlinksFilePath("path/to/dir", ".geesefs_symlinks"), Equals, "path/to/dir/.geesefs_symlinks") } func (s *SymlinksTest) TestSymlinksToFilesAndDirectories(t *C) { @@ -207,12 +207,12 @@ func (s *SymlinksTest) TestSaveSymlinksFileCreateNew(t *C) { data.AddSymlink("link1", "../target1") // Save new file (no expectedETag) - etag, err := SaveSymlinksFile(mock, "testdir", ".symlinks", data, "") + etag, err := SaveSymlinksFile(mock, "testdir", ".geesefs_symlinks", data, "") t.Assert(err, IsNil) t.Assert(etag != "", Equals, true) // Verify it was saved - _, exists := mock.objects["testdir/.symlinks"] + _, exists := mock.objects["testdir/.geesefs_symlinks"] t.Assert(exists, Equals, true) } @@ -220,7 +220,7 @@ func (s *SymlinksTest) TestSaveSymlinksFileCreateNewPreventsOverwrite(t *C) { mock := newMockConditionalBackend() // Pre-create an existing file - mock.objects["testdir/.symlinks"] = &mockStoredObject{ + mock.objects["testdir/.geesefs_symlinks"] = &mockStoredObject{ data: []byte(`{"version":1,"symlinks":{}}`), etag: "\"existing\"", } @@ -229,7 +229,7 @@ func (s *SymlinksTest) TestSaveSymlinksFileCreateNewPreventsOverwrite(t *C) { data.AddSymlink("link1", "../target1") // Try to create new file (no expectedETag) - should fail because file exists - _, err := SaveSymlinksFile(mock, "testdir", ".symlinks", data, "") + _, err := SaveSymlinksFile(mock, "testdir", ".geesefs_symlinks", data, "") t.Assert(err, NotNil) t.Assert(err.Error(), Matches, ".*PreconditionFailed.*") } @@ -239,7 +239,7 @@ func (s *SymlinksTest) TestSaveSymlinksFileUpdateWithCorrectETag(t *C) { // Pre-create an existing file existingETag := "\"existing-etag\"" - mock.objects["testdir/.symlinks"] = &mockStoredObject{ + mock.objects["testdir/.geesefs_symlinks"] = &mockStoredObject{ data: []byte(`{"version":1,"symlinks":{"old":"../old-target"}}`), etag: existingETag, } @@ -248,7 +248,7 @@ func (s *SymlinksTest) TestSaveSymlinksFileUpdateWithCorrectETag(t *C) { data.AddSymlink("link1", "../target1") // Update with correct ETag - should succeed - newETag, err := SaveSymlinksFile(mock, "testdir", ".symlinks", data, existingETag) + newETag, err := SaveSymlinksFile(mock, "testdir", ".geesefs_symlinks", data, existingETag) t.Assert(err, IsNil) t.Assert(newETag != "", Equals, true) t.Assert(newETag != existingETag, Equals, true) @@ -258,7 +258,7 @@ func (s *SymlinksTest) TestSaveSymlinksFileUpdateWithWrongETag(t *C) { mock := newMockConditionalBackend() // Pre-create an existing file - mock.objects["testdir/.symlinks"] = &mockStoredObject{ + mock.objects["testdir/.geesefs_symlinks"] = &mockStoredObject{ data: []byte(`{"version":1,"symlinks":{"old":"../old-target"}}`), etag: "\"actual-etag\"", } @@ -267,7 +267,7 @@ func (s *SymlinksTest) TestSaveSymlinksFileUpdateWithWrongETag(t *C) { data.AddSymlink("link1", "../target1") // Update with wrong ETag - should fail (optimistic locking) - _, err := SaveSymlinksFile(mock, "testdir", ".symlinks", data, "\"wrong-etag\"") + _, err := SaveSymlinksFile(mock, "testdir", ".geesefs_symlinks", data, "\"wrong-etag\"") t.Assert(err, NotNil) t.Assert(err.Error(), Matches, ".*PreconditionFailed.*") } @@ -277,12 +277,12 @@ func (s *SymlinksTest) TestLoadSymlinksFile(t *C) { // Pre-create a file existingETag := "\"test-etag\"" - mock.objects["testdir/.symlinks"] = &mockStoredObject{ + mock.objects["testdir/.geesefs_symlinks"] = &mockStoredObject{ data: []byte(`{"version":1,"symlinks":{"link1":{"target":"../target1"}}}`), etag: existingETag, } - data, etag, err := LoadSymlinksFile(mock, "testdir", ".symlinks") + data, etag, err := LoadSymlinksFile(mock, "testdir", ".geesefs_symlinks") t.Assert(err, IsNil) t.Assert(etag, Equals, existingETag) t.Assert(data.HasSymlink("link1"), Equals, true) @@ -295,7 +295,7 @@ func (s *SymlinksTest) TestLoadSymlinksFile(t *C) { func (s *SymlinksTest) TestLoadSymlinksFileNotFound(t *C) { mock := newMockConditionalBackend() - data, etag, err := LoadSymlinksFile(mock, "testdir", ".symlinks") + data, etag, err := LoadSymlinksFile(mock, "testdir", ".geesefs_symlinks") t.Assert(err, IsNil) t.Assert(etag, Equals, "") t.Assert(data.IsEmpty(), Equals, true) @@ -305,15 +305,15 @@ func (s *SymlinksTest) TestDeleteSymlinksFile(t *C) { mock := newMockConditionalBackend() // Pre-create a file - mock.objects["testdir/.symlinks"] = &mockStoredObject{ + mock.objects["testdir/.geesefs_symlinks"] = &mockStoredObject{ data: []byte(`{"version":1,"symlinks":{}}`), etag: "\"test\"", } - err := DeleteSymlinksFile(mock, "testdir", ".symlinks") + err := DeleteSymlinksFile(mock, "testdir", ".geesefs_symlinks") t.Assert(err, IsNil) - _, exists := mock.objects["testdir/.symlinks"] + _, exists := mock.objects["testdir/.geesefs_symlinks"] t.Assert(exists, Equals, false) } @@ -321,17 +321,17 @@ func (s *SymlinksTest) TestSaveEmptySymlinksFileDeletesExisting(t *C) { mock := newMockConditionalBackend() // Pre-create an existing file - mock.objects["testdir/.symlinks"] = &mockStoredObject{ + mock.objects["testdir/.geesefs_symlinks"] = &mockStoredObject{ data: []byte(`{"version":1,"symlinks":{"link1":{"target":"../target1"}}}`), etag: "\"existing\"", } // Save empty data with existing ETag should delete the file emptyData := NewSymlinksFileData() - _, err := SaveSymlinksFile(mock, "testdir", ".symlinks", emptyData, "\"existing\"") + _, err := SaveSymlinksFile(mock, "testdir", ".geesefs_symlinks", emptyData, "\"existing\"") t.Assert(err, IsNil) - _, exists := mock.objects["testdir/.symlinks"] + _, exists := mock.objects["testdir/.geesefs_symlinks"] t.Assert(exists, Equals, false) } @@ -346,26 +346,26 @@ func (s *SymlinksTest) TestConcurrentSymlinkCreation(t *C) { data2.AddSymlink("link2", "../target2") // First creation succeeds - etag1, err := SaveSymlinksFile(mock, "testdir", ".symlinks", data1, "") + etag1, err := SaveSymlinksFile(mock, "testdir", ".geesefs_symlinks", data1, "") t.Assert(err, IsNil) t.Assert(etag1 != "", Equals, true) // Second creation fails (file already exists) - _, err = SaveSymlinksFile(mock, "testdir", ".symlinks", data2, "") + _, err = SaveSymlinksFile(mock, "testdir", ".geesefs_symlinks", data2, "") t.Assert(err, NotNil) // Proper way: load, merge, save with ETag - existingData, etag, err := LoadSymlinksFile(mock, "testdir", ".symlinks") + existingData, etag, err := LoadSymlinksFile(mock, "testdir", ".geesefs_symlinks") t.Assert(err, IsNil) t.Assert(etag, Equals, etag1) existingData.AddSymlink("link2", "../target2") - newETag, err := SaveSymlinksFile(mock, "testdir", ".symlinks", existingData, etag) + newETag, err := SaveSymlinksFile(mock, "testdir", ".geesefs_symlinks", existingData, etag) t.Assert(err, IsNil) t.Assert(newETag != etag, Equals, true) // Verify both symlinks exist - finalData, _, err := LoadSymlinksFile(mock, "testdir", ".symlinks") + finalData, _, err := LoadSymlinksFile(mock, "testdir", ".geesefs_symlinks") t.Assert(err, IsNil) t.Assert(finalData.HasSymlink("link1"), Equals, true) t.Assert(finalData.HasSymlink("link2"), Equals, true) @@ -387,7 +387,7 @@ func (s *SymlinksTest) TestSaveSymlinksFileUsesIfNoneMatchForNewFile(t *C) { data.AddSymlink("link1", "../target1") // Save new file - should use If-None-Match: "*" - _, err := SaveSymlinksFile(mock, "testdir", ".symlinks", data, "") + _, err := SaveSymlinksFile(mock, "testdir", ".geesefs_symlinks", data, "") t.Assert(err, IsNil) t.Assert(capturedIfNoneMatch, NotNil) t.Assert(*capturedIfNoneMatch, Equals, "*") @@ -398,7 +398,7 @@ func (s *SymlinksTest) TestSaveSymlinksFileUsesIfMatchForUpdate(t *C) { // Pre-create existing file existingETag := "\"existing-etag\"" - mock.objects["testdir/.symlinks"] = &mockStoredObject{ + mock.objects["testdir/.geesefs_symlinks"] = &mockStoredObject{ data: []byte(`{"version":1,"symlinks":{}}`), etag: existingETag, } @@ -412,7 +412,7 @@ func (s *SymlinksTest) TestSaveSymlinksFileUsesIfMatchForUpdate(t *C) { data.AddSymlink("link1", "../target1") // Update with ETag - should use If-Match - _, err := SaveSymlinksFile(mock, "testdir", ".symlinks", data, existingETag) + _, err := SaveSymlinksFile(mock, "testdir", ".geesefs_symlinks", data, existingETag) t.Assert(err, IsNil) t.Assert(capturedIfMatch, NotNil) t.Assert(*capturedIfMatch, Equals, existingETag) @@ -433,7 +433,7 @@ func (s *SymlinksTest) TestSaveWithRetrySucceedsOnFirstAttempt(t *C) { return nil, nil } - newETag, err := SaveSymlinksFileWithRetry(mock, "testdir", ".symlinks", data, "", mergeFn, 3) + newETag, err := SaveSymlinksFileWithRetry(mock, "testdir", ".geesefs_symlinks", data, "", mergeFn, 3) t.Assert(err, IsNil) t.Assert(newETag, Not(Equals), "") } @@ -442,7 +442,7 @@ func (s *SymlinksTest) TestSaveWithRetryRetriesOnConflict(t *C) { mock := newMockConditionalBackend() // Pre-create a file to cause initial conflict - mock.objects["testdir/.symlinks"] = &mockStoredObject{ + mock.objects["testdir/.geesefs_symlinks"] = &mockStoredObject{ data: []byte(`{"version":1,"symlinks":{"existing":{"target":"../old","mtime":1}}}`), etag: "\"etag-v1\"", } @@ -459,13 +459,13 @@ func (s *SymlinksTest) TestSaveWithRetryRetriesOnConflict(t *C) { } // Try to create (If-None-Match: "*") - will fail, then retry with merge - newETag, err := SaveSymlinksFileWithRetry(mock, "testdir", ".symlinks", data, "", mergeFn, 3) + newETag, err := SaveSymlinksFileWithRetry(mock, "testdir", ".geesefs_symlinks", data, "", mergeFn, 3) t.Assert(err, IsNil) t.Assert(newETag, Not(Equals), "") t.Assert(mergeCallCount, Equals, 1) // Verify merged result contains both symlinks - obj := mock.objects["testdir/.symlinks"] + obj := mock.objects["testdir/.geesefs_symlinks"] parsed, _ := ParseSymlinksFile(obj.data) t.Assert(parsed.HasSymlink("existing"), Equals, true) t.Assert(parsed.HasSymlink("newlink"), Equals, true) @@ -486,7 +486,7 @@ func (s *SymlinksTest) TestSaveWithRetryExceedsMaxRetries(t *C) { return data, nil } - _, err := SaveSymlinksFileWithRetry(failingMock, "testdir", ".symlinks", data, "", mergeFn, 2) + _, err := SaveSymlinksFileWithRetry(failingMock, "testdir", ".geesefs_symlinks", data, "", mergeFn, 2) t.Assert(err, NotNil) t.Assert(err.Error(), Matches, ".*max retries.*exceeded.*") t.Assert(mergeCallCount, Equals, 2) // Should have tried merge twice @@ -496,7 +496,7 @@ func (s *SymlinksTest) TestSaveWithRetryMergeFunctionError(t *C) { mock := newMockConditionalBackend() // Pre-create a file to cause conflict - mock.objects["testdir/.symlinks"] = &mockStoredObject{ + mock.objects["testdir/.geesefs_symlinks"] = &mockStoredObject{ data: []byte(`{"version":1,"symlinks":{}}`), etag: "\"etag-v1\"", } @@ -508,7 +508,7 @@ func (s *SymlinksTest) TestSaveWithRetryMergeFunctionError(t *C) { return nil, fmt.Errorf("merge conflict: cannot resolve") } - _, err := SaveSymlinksFileWithRetry(mock, "testdir", ".symlinks", data, "", mergeFn, 3) + _, err := SaveSymlinksFileWithRetry(mock, "testdir", ".geesefs_symlinks", data, "", mergeFn, 3) t.Assert(err, NotNil) t.Assert(err.Error(), Matches, ".*merge function failed.*") } @@ -528,7 +528,7 @@ func (s *SymlinksTest) TestSaveWithRetryNoRetriesOnOtherErrors(t *C) { return data, nil } - _, err := SaveSymlinksFileWithRetry(errorMock, "testdir", ".symlinks", data, "", mergeFn, 3) + _, err := SaveSymlinksFileWithRetry(errorMock, "testdir", ".geesefs_symlinks", data, "", mergeFn, 3) t.Assert(err, NotNil) t.Assert(err.Error(), Matches, ".*network error.*") t.Assert(mergeCallCount, Equals, 0) // Should not have tried merge @@ -538,7 +538,7 @@ func (s *SymlinksTest) TestSaveWithRetryZeroMaxRetries(t *C) { mock := newMockConditionalBackend() // Pre-create a file to cause conflict - mock.objects["testdir/.symlinks"] = &mockStoredObject{ + mock.objects["testdir/.geesefs_symlinks"] = &mockStoredObject{ data: []byte(`{"version":1,"symlinks":{}}`), etag: "\"etag-v1\"", } @@ -553,7 +553,7 @@ func (s *SymlinksTest) TestSaveWithRetryZeroMaxRetries(t *C) { } // With maxRetries=0, should fail immediately on conflict - _, err := SaveSymlinksFileWithRetry(mock, "testdir", ".symlinks", data, "", mergeFn, 0) + _, err := SaveSymlinksFileWithRetry(mock, "testdir", ".geesefs_symlinks", data, "", mergeFn, 0) t.Assert(err, NotNil) t.Assert(err.Error(), Matches, ".*max retries.*exceeded.*") t.Assert(mergeCallCount, Equals, 0) From 7780cda66beac4eabbcda82b71c4ccbfa21775ea Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Thu, 29 Jan 2026 17:25:55 +0100 Subject: [PATCH 05/39] fixes --- core/dir.go | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/core/dir.go b/core/dir.go index a900b1c3..0618928e 100644 --- a/core/dir.go +++ b/core/dir.go @@ -503,6 +503,35 @@ func (dh *DirHandle) handleListResult(resp *ListBlobsOutput, prefix string, skip dh.inode.dir.lastFromCloud = &baseName } } + + // Create virtual symlink inodes from symlinks cache (for symlinks not backed by S3 objects) + if fs.flags.EnableSymlinksFile && parent.dir.symlinksCache != nil { + now := time.Now() + for name, entry := range parent.dir.symlinksCache.Symlinks { + // Skip if inode already exists (from S3 listing or previous creation) + if parent.findChildUnlocked(name) != nil { + continue + } + // Skip if deleted + if _, deleted := parent.dir.DeletedChildren[name]; deleted { + continue + } + // Create virtual symlink inode + inode := NewInode(fs, parent, name) + inode.userMetadata = make(map[string][]byte) + inode.userMetadata[fs.flags.SymlinkAttr] = []byte(entry.Target) + inode.Attributes = InodeAttributes{ + Size: 0, + Mtime: time.Unix(entry.Mtime, 0), + Ctime: now, + Uid: fs.flags.Uid, + Gid: fs.flags.Gid, + Mode: fs.flags.FileMode, + } + fs.insertInode(parent, inode) + inode.SetCacheState(ST_CACHED) + } + } } func maxName(resp *ListBlobsOutput, itemPos, prefixPos int) (string, int) { @@ -813,6 +842,35 @@ func (parent *Inode) removeExpired(from string) { if parent.dir.lastFromCloud != nil && childTmp.Name >= *parent.dir.lastFromCloud { break } + // For virtual symlinks (stored in .geesefs_symlinks file, not as S3 objects), + // check if they still exist in the symlinks cache + if parent.fs.flags.EnableSymlinksFile { + childTmp.mu.Lock() + isVirtualSymlink := childTmp.userMetadata != nil && childTmp.userMetadata[parent.fs.flags.SymlinkAttr] != nil + childTmp.mu.Unlock() + if isVirtualSymlink { + // Check if symlink still exists in cache (cache was refreshed in loadListing) + if parent.dir.symlinksCache != nil { + if _, exists := parent.dir.symlinksCache.Symlinks[childTmp.Name]; exists { + // Symlink still exists in cache, keep it + continue + } + } + // Symlink not in cache anymore, remove it + childTmp.mu.Lock() + childTmp.resetCache() + childTmp.SetCacheState(ST_DEAD) + notifications = append(notifications, &fuseops.NotifyDelete{ + Parent: parent.Id, + Child: childTmp.Id, + Name: childTmp.Name, + }) + parent.removeChildUnlocked(childTmp) + childTmp.mu.Unlock() + i-- + continue + } + } if childTmp.AttrTime.Before(parent.dir.refreshStartTime) && atomic.LoadInt32(&childTmp.fileHandles) == 0 && atomic.LoadInt32(&childTmp.CacheState) <= ST_DEAD && @@ -2144,6 +2202,41 @@ func (parent *Inode) LookUp(name string, doSlurp bool) (*Inode, error) { parent.mu.Unlock() return inode, nil } + + // For virtual symlinks (EnableSymlinksFile), check symlinks cache before S3 lookup + // Virtual symlinks don't have S3 objects, so S3 lookup would fail + if parent.fs.flags.EnableSymlinksFile { + parent.mu.Lock() + if err := parent.loadSymlinksCache(); err != nil { + s3Log.Warnf("Failed to load symlinks cache for %v: %v", parent.FullName(), err) + } + // Check if the name is a virtual symlink + if target, ok := parent.getSymlinkTargetFromCache(name); ok { + // Check if inode already exists + inode := parent.findChildUnlocked(name) + if inode == nil { + // Create virtual symlink inode + inode = NewInode(parent.fs, parent, name) + inode.userMetadata = make(map[string][]byte) + inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) + now := time.Now() + inode.Attributes = InodeAttributes{ + Size: 0, + Mtime: now, + Ctime: now, + Uid: parent.fs.flags.Uid, + Gid: parent.fs.flags.Gid, + Mode: parent.fs.flags.FileMode, + } + parent.fs.insertInode(parent, inode) + inode.SetCacheState(ST_CACHED) + } + parent.mu.Unlock() + return inode, nil + } + parent.mu.Unlock() + } + if doSlurp { // 99% of time it's impractical to do 2 HEAD requests per file when looking it up // So we first try to preload a whole batch of files starting with our key From a8fea614573aa233b0d8ab8b92c7b51ee7219dc6 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Thu, 29 Jan 2026 17:26:15 +0100 Subject: [PATCH 06/39] docs and tests --- doc/symlinks-file-design.md | 116 ++++++++ docker/Dockerfile.geesefs | 56 ++++ docker/Dockerfile.test-conditional | 13 + docker/README.md | 155 ++++++++++ docker/docker-compose.yml | 165 +++++++++++ docker/docker-entrypoint.sh | 35 +++ docker/justfile | 145 ++++++++++ docker/test_conditional_writes.py | 412 +++++++++++++++++++++++++++ docker/test_geesefs_conditional.sh | 440 +++++++++++++++++++++++++++++ docker/test_symlinks_file.sh | 370 ++++++++++++++++++++++++ 10 files changed, 1907 insertions(+) create mode 100644 doc/symlinks-file-design.md create mode 100644 docker/Dockerfile.geesefs create mode 100644 docker/Dockerfile.test-conditional create mode 100644 docker/README.md create mode 100644 docker/docker-compose.yml create mode 100644 docker/docker-entrypoint.sh create mode 100644 docker/justfile create mode 100644 docker/test_conditional_writes.py create mode 100755 docker/test_geesefs_conditional.sh create mode 100755 docker/test_symlinks_file.sh diff --git a/doc/symlinks-file-design.md b/doc/symlinks-file-design.md new file mode 100644 index 00000000..ba73d047 --- /dev/null +++ b/doc/symlinks-file-design.md @@ -0,0 +1,116 @@ +# Symlinks File Feature Design Document + +## Overview + +When `--enable-symlinks-file` is enabled, GeeseFS stores symlink metadata in a `.geesefs_symlinks` JSON file per directory. This enables cross-mount visibility of symlinks without requiring immediate cache invalidation. + +## Problem Statement + +When a symlink is created on one GeeseFS mount, other mounts need to be able to discover it. A naive approach that relies on per-object metadata would require each mount to HEAD every object, which is expensive. + +## Solution: `.geesefs_symlinks` File + +The `.geesefs_symlinks` file is the **authoritative source** for symlink information during normal operation. + +```json +{ + "version": 1, + "symlinks": { + "link-name.txt": { + "target": "target-file.txt", + "mtime": 1706400000 + } + } +} +``` + +**Benefits:** + +- Single GET request retrieves all symlinks in a directory +- Conditional GET (If-None-Match) enables efficient cache validation +- Atomic updates via conditional PUT (If-Match) + +## Behavior Matrix + +*When `--enable-symlinks-file` is enabled:* + +| `.geesefs_symlinks` has entry | Result | +|----------------------|--------| +| Yes | ✅ Symlink (from `.geesefs_symlinks` cache) | +| No | Regular file or nothing (symlink not recognized) | + +## Directory Listing Flow + +```text +1. loadListing() called + │ +2. loadSymlinksCache() called FIRST + │ + ├─ If .geesefs_symlinks exists → Load into cache + │ + └─ If .geesefs_symlinks missing → Empty cache + │ +3. ListBlobs returns objects + │ +4. insertSubTree processes each object + │ + └─ Check symlinksCache for each name + │ + ├─ If in cache → Apply symlink mode + └─ If not in cache → Regular file + │ + +``` + +## Edge Cases + +### `.geesefs_symlinks` Deleted During Listing + +If `.geesefs_symlinks` is deleted while a mount is mid-listing: + +- Symlinks may temporarily appear missing until `.geesefs_symlinks` is restored +- No automatic rebuild occurs; `.geesefs_symlinks` must be recreated manually + +**Mitigation:** Restore `.geesefs_symlinks` manually if needed. + +### Manual Recovery (No Auto-Rebuild) + +If `.geesefs_symlinks` is deleted, the system will not reconstruct it automatically. Operators must restore it by one of the following: + +- Recreate `.geesefs_symlinks` from a backup, or +- Recreate the symlinks (which will repopulate `.geesefs_symlinks`), or +- Manually author a valid `.geesefs_symlinks` JSON and upload it. + +## Implementation Details + +### CreateSymlink (with EnableSymlinksFile=true) + +1. Update `.geesefs_symlinks` file (conditional PUT) +2. Insert inode with `ST_CACHED` state (no S3 object created—symlink is purely virtual) + +### Unlink (symlink with EnableSymlinksFile=true) + +1. Update `.geesefs_symlinks` file to remove entry +2. Remove inode from cache (no S3 deletion needed—symlink is purely virtual) + +## Performance Considerations + +| Operation | Without EnableSymlinksFile | With EnableSymlinksFile | +|-----------|---------------------------|------------------------| +| Create symlink | 1 PUT (object with metadata) | 1 PUT (`.geesefs_symlinks`) | +| Delete symlink | 1 DELETE | 1 PUT (`.geesefs_symlinks`) | +| List directory | N HEADs to find symlinks | 1 GET (`.geesefs_symlinks`) | + +**Trade-off:** Lower write and delete cost (no per-symlink S3 objects). If `.geesefs_symlinks` is deleted, symlinks are not visible until manual recovery is performed. + +## Configuration + +```text +--enable-symlinks-file Store symlinks in .geesefs_symlinks file (default: false) +--symlinks-file NAME Name of symlinks file (default: .geesefs_symlinks) +``` + +## Future Considerations + +1. **Batch operations:** Combine multiple symlink creates into single `.geesefs_symlinks` update +2. **Compression:** Compress `.geesefs_symlinks` file for directories with many symlinks diff --git a/docker/Dockerfile.geesefs b/docker/Dockerfile.geesefs new file mode 100644 index 00000000..be93ed0c --- /dev/null +++ b/docker/Dockerfile.geesefs @@ -0,0 +1,56 @@ +# Dockerfile for running GeeseFS with FUSE support +FROM golang:1.25-alpine AS builder + +# Install required packages for building +RUN apk add --no-cache git make bash + +# Set working directory +WORKDIR /build + +# Copy go.mod and go.sum first for better caching +COPY go.mod go.sum ./ + +# Copy the s3ext module (local replace directive) +COPY s3ext/ ./s3ext/ + +# Download dependencies +RUN go mod download + +# Copy the rest of the source code +COPY . . + +# Set environment variables for static build +ENV CGO_ENABLED=0 +ENV GOOS=linux + +# Build binary +RUN go build -ldflags "-X main.Version=docker-build -s -w" -o geesefs . + +# Runtime stage with FUSE support +FROM alpine:latest + +# Install FUSE and other runtime dependencies +RUN apk add --no-cache \ + fuse \ + fuse-common \ + ca-certificates \ + tzdata \ + bash + +# Copy the binary from builder +COPY --from=builder /build/geesefs /geesefs + +# Create mount point directory +RUN mkdir -p /mnt/s3 + +# Create log directory +RUN mkdir -p /var/log + +# Enable FUSE for non-root users +RUN echo "user_allow_other" >> /etc/fuse.conf + +# Copy entrypoint script +COPY docker/docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/docker/Dockerfile.test-conditional b/docker/Dockerfile.test-conditional new file mode 100644 index 00000000..edd2bec1 --- /dev/null +++ b/docker/Dockerfile.test-conditional @@ -0,0 +1,13 @@ +# Dockerfile for conditional write test container +FROM python:3.12-alpine + +# Install required packages +RUN pip install --no-cache-dir boto3 + +# Create app directory +WORKDIR /app + +# Copy test script +COPY test_conditional_writes.py /app/ + +CMD ["python3", "/app/test_conditional_writes.py"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..497f966e --- /dev/null +++ b/docker/README.md @@ -0,0 +1,155 @@ +# Docker Setup for GeeseFS + +This folder contains Docker configurations for testing GeeseFS with MinIO. + +## Prerequisites + +- Docker and Docker Compose +- [just](https://github.com/casey/just) command runner (recommended) + +## Services + +- **minio**: MinIO S3-compatible storage server +- **minio-init**: Initializes the test bucket with proper permissions +- **geesefs-1**: First GeeseFS container mounting the S3 bucket (with `--enable-symlinks-file`) +- **geesefs-2**: Second GeeseFS container mounting the same bucket (with `--enable-symlinks-file`) +- **conditional-write-test**: Python test container for S3 conditional writes +- **symlinks-test**: Shell script test container for symlinks file feature +- **test-client**: MinIO client for verifying bucket contents + +## Quick Start with Just (Recommended) + +The project includes a `justfile` with convenient commands. Run `just` to see all available recipes. + +```bash +# From the docker/ directory +cd docker + +# Show all available commands +just + +# Start all services +just up + +# Run all tests +just test + +# Run specific tests +just test-conditional # S3 conditional write tests +just test-symlinks # Symlinks file tests +just test-geesefs-conditional # GeeseFS conditional tests via FUSE + +# View GeeseFS logs +just logs + +# Open MinIO Console in browser +just console + +# Test writing/reading across containers +just write-test +just read-test + +# Test symlinks +just create-symlink +just read-symlink + +# List bucket contents +just ls +just ls-recursive +just ls-symlinks + +# Shell into containers +just shell-1 # GeeseFS container 1 +just shell-2 # GeeseFS container 2 +just shell-minio + +# Check service health +just health + +# Stop all services +just down + +# Stop and remove volumes +just clean + +# Rebuild containers +just rebuild # Rebuild and restart GeeseFS containers +just rebuild-clean # Full rebuild with clean volumes +just full-rebuild # Clean, build, and start everything +``` + +## Quick Start with Docker Compose + +If you prefer using Docker Compose directly: + +```bash +# From the docker/ directory +cd docker + +# Start all services +docker compose up -d + +# Run conditional write tests +docker compose run --rm conditional-write-test + +# Run symlinks file tests +docker compose run --rm symlinks-test + +# View GeeseFS logs +docker compose logs geesefs-1 geesefs-2 + +# Access MinIO Console +open http://localhost:9001 # user: minioadmin, password: minioadmin + +# Test writing from one container and reading from another +docker exec geesefs-mount-1 sh -c 'echo "Hello" > /mnt/s3/test.txt' +docker exec geesefs-mount-2 cat /mnt/s3/test.txt + +# Test symlinks +docker exec geesefs-mount-1 sh -c 'echo "target content" > /mnt/s3/target.txt' +docker exec geesefs-mount-1 ln -s target.txt /mnt/s3/link.txt +docker exec geesefs-mount-2 cat /mnt/s3/link.txt + +# Stop all services +docker compose down + +# Stop and remove volumes +docker compose down -v +``` + +## Configuration + +Environment variables for GeeseFS containers: + +| Variable | Default | Description | +|----------|---------|-------------| +| `AWS_ACCESS_KEY_ID` | minioadmin | S3 access key | +| `AWS_SECRET_ACCESS_KEY` | minioadmin | S3 secret key | +| `S3_ENDPOINT` | | S3 endpoint URL | +| `S3_BUCKET` | testbucket | Bucket to mount | +| `MOUNT_POINT` | /mnt/s3 | Mount location | +| `GEESEFS_OPTS` | --debug_s3 --enable-symlinks-file | Additional GeeseFS options | + +## Symlinks File Tests + +The `symlinks-test` container tests the `--enable-symlinks-file` feature which stores symlink metadata in a hidden `.symlinks` JSON file per directory instead of object metadata. This is useful for S3 backends that don't return UserMetadata in listings. + +Tests include: + +1. **Create symlink** - Verify `.symlinks` file is created with correct JSON structure +2. **Cross-container visibility** - Symlinks created in container 1 are visible in container 2 +3. **Subdirectory symlinks** - Each directory has its own `.symlinks` file +4. **Delete symlink** - Verify `.symlinks` file is updated when symlink is removed +5. **Bidirectional sync** - Symlinks created in container 2 are visible in container 1 +6. **Hidden file** - `.symlinks` file is not visible in directory listings + +## Conditional Write Tests + +The `conditional-write-test` container tests S3 conditional write features: + +1. **If-None-Match: "*"** - Create only if object doesn't exist +2. **If-Match: \** - Update only if ETag matches (optimistic locking) +3. **Concurrent write race** - 5 writers, only 1 succeeds +4. **Read-modify-write pattern** - Counter increments with retry on conflict + +These features (added to S3 in August 2024) prevent race conditions when multiple writers access the same object. diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 00000000..0e4b9d79 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,165 @@ +services: + # MinIO S3-compatible storage + minio: + image: minio/minio:latest + container_name: geesefs-minio + command: server /data --console-address ":9001" + ports: + - "9000:9000" # S3 API + - "9001:9001" # MinIO Console + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + volumes: + - minio-data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - geesefs-network + + # MinIO client to create bucket on startup + minio-init: + image: minio/mc:latest + container_name: geesefs-minio-init + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set myminio http://minio:9000 minioadmin minioadmin; + mc mb myminio/testbucket --ignore-existing; + mc anonymous set public myminio/testbucket; + echo 'Bucket testbucket created and configured'; + exit 0; + " + networks: + - geesefs-network + + # GeeseFS container 1 - mounts the S3 bucket + geesefs-1: + build: + context: .. + dockerfile: docker/Dockerfile.geesefs + container_name: geesefs-mount-1 + depends_on: + minio-init: + condition: service_completed_successfully + privileged: true + cap_add: + - SYS_ADMIN + devices: + - /dev/fuse + security_opt: + - apparmor:unconfined + environment: + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin + S3_ENDPOINT: http://minio:9000 + S3_BUCKET: testbucket + MOUNT_POINT: /mnt/s3 + GEESEFS_OPTS: "--debug_s3 --debug_fuse --enable-symlinks-file --stat-cache-ttl 1s" + networks: + - geesefs-network + healthcheck: + test: ["CMD", "mountpoint", "-q", "/mnt/s3"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + # GeeseFS container 2 - mounts the same S3 bucket + geesefs-2: + build: + context: .. + dockerfile: docker/Dockerfile.geesefs + container_name: geesefs-mount-2 + depends_on: + minio-init: + condition: service_completed_successfully + privileged: true + cap_add: + - SYS_ADMIN + devices: + - /dev/fuse + security_opt: + - apparmor:unconfined + environment: + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin + S3_ENDPOINT: http://minio:9000 + S3_BUCKET: testbucket + MOUNT_POINT: /mnt/s3 + GEESEFS_OPTS: "--debug_s3 --debug_fuse --enable-symlinks-file --stat-cache-ttl 1s" + networks: + - geesefs-network + healthcheck: + test: ["CMD", "mountpoint", "-q", "/mnt/s3"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + # Test container for conditional write tests using S3 API directly + conditional-write-test: + build: + context: . + dockerfile: Dockerfile.test-conditional + container_name: geesefs-conditional-test + depends_on: + geesefs-1: + condition: service_healthy + geesefs-2: + condition: service_healthy + environment: + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin + S3_ENDPOINT: http://minio:9000 + S3_BUCKET: testbucket + networks: + - geesefs-network + command: ["python3", "/app/test_conditional_writes.py"] + + # Test container that uses MinIO client to verify files + test-client: + image: minio/mc:latest + container_name: geesefs-test-client + depends_on: + geesefs-1: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set myminio http://minio:9000 minioadmin minioadmin; + echo 'Listing bucket contents:'; + mc ls myminio/testbucket; + echo 'Test completed!'; + sleep infinity + " + networks: + - geesefs-network + + # Test container for symlinks file tests + symlinks-test: + image: docker:cli + container_name: geesefs-symlinks-test + depends_on: + geesefs-1: + condition: service_healthy + geesefs-2: + condition: service_healthy + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./test_symlinks_file.sh:/test_symlinks_file.sh:ro + working_dir: / + command: ["sh", "/test_symlinks_file.sh"] + networks: + - geesefs-network + +volumes: + minio-data: + +networks: + geesefs-network: + driver: bridge diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100644 index 00000000..dd481d27 --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e + +# Default values +S3_ENDPOINT="${S3_ENDPOINT:-http://minio:9000}" +S3_BUCKET="${S3_BUCKET:-testbucket}" +MOUNT_POINT="${MOUNT_POINT:-/mnt/s3}" +GEESEFS_OPTS="${GEESEFS_OPTS:-}" + +# Ensure mount point exists +mkdir -p "$MOUNT_POINT" + +echo "GeeseFS Docker Container" +echo "========================" +echo "S3 Endpoint: $S3_ENDPOINT" +echo "Bucket: $S3_BUCKET" +echo "Mount Point: $MOUNT_POINT" +echo "" + +# If arguments are passed, run geesefs with those arguments +if [ $# -gt 0 ]; then + echo "Running: /geesefs $@" + exec /geesefs "$@" +fi + +# Default: mount the S3 bucket +echo "Mounting S3 bucket '$S3_BUCKET' to '$MOUNT_POINT'..." +echo "Running: /geesefs --endpoint $S3_ENDPOINT -o allow_other -f $GEESEFS_OPTS $S3_BUCKET $MOUNT_POINT" + +exec /geesefs \ + --endpoint "$S3_ENDPOINT" \ + -o allow_other \ + -f \ + $GEESEFS_OPTS \ + "$S3_BUCKET" "$MOUNT_POINT" diff --git a/docker/justfile b/docker/justfile new file mode 100644 index 00000000..68df5569 --- /dev/null +++ b/docker/justfile @@ -0,0 +1,145 @@ +# GeeseFS Docker Test Runner +# Usage: just + +# Default recipe - show help +default: + @just --list + +# Build all Docker images +build: + docker compose build + +# Start all services (MinIO + GeeseFS containers) +up: + docker compose up -d + +# Stop all services +down: + docker compose down + +# Stop all services and remove volumes +clean: + docker compose down -v + +# View logs from GeeseFS containers +logs: + docker compose logs -f geesefs-1 geesefs-2 + +# View logs from MinIO +logs-minio: + docker compose logs -f minio + +# Run all tests +test: up + @echo "Running all tests..." + @just test-conditional + @just test-symlinks + @echo "All tests completed!" + +# Run S3 conditional write tests (If-Match/If-None-Match) +test-conditional: up + @echo "Running conditional write tests..." + docker compose run --rm conditional-write-test + +# Run symlinks file tests +test-symlinks: up + @echo "Running symlinks file tests..." + docker compose run --rm symlinks-test + +# Run GeeseFS conditional tests via FUSE mount +test-geesefs-conditional: up + @echo "Running GeeseFS conditional tests..." + @chmod +x test_geesefs_conditional.sh + ./test_geesefs_conditional.sh + +# Check health of all services +health: + @echo "Checking service health..." + @docker compose ps + @echo "" + @echo "Mount status:" + @docker exec geesefs-mount-1 mountpoint -q /mnt/s3 && echo " geesefs-1: mounted" || echo " geesefs-1: NOT mounted" + @docker exec geesefs-mount-2 mountpoint -q /mnt/s3 && echo " geesefs-2: mounted" || echo " geesefs-2: NOT mounted" + +# Open MinIO console in browser +console: + open http://localhost:9001 + +# List bucket contents via MinIO client +ls: + docker exec geesefs-minio mc ls myminio/testbucket/ + +# List bucket contents recursively +ls-recursive: + docker exec geesefs-minio mc ls --recursive myminio/testbucket/ + +# Show .symlinks files in bucket +ls-symlinks: + @echo "Looking for .symlinks files..." + docker exec geesefs-minio mc find myminio/testbucket --name ".symlinks" --print || echo "No .symlinks files found" + +# Cat a .symlinks file (usage: just cat-symlinks [path]) +cat-symlinks path="": + @if [ -z "{{path}}" ]; then \ + docker exec geesefs-minio mc cat myminio/testbucket/.symlinks 2>/dev/null || echo "No .symlinks file in root"; \ + else \ + docker exec geesefs-minio mc cat myminio/testbucket/{{path}}/.symlinks 2>/dev/null || echo "No .symlinks file in {{path}}"; \ + fi + +# Create a test file from container 1 +write-test: + docker exec geesefs-mount-1 sh -c 'echo "Hello from container 1" > /mnt/s3/test-file.txt' + @echo "Created test-file.txt" + +# Read test file from container 2 +read-test: + docker exec geesefs-mount-2 cat /mnt/s3/test-file.txt + +# Create a symlink test +create-symlink: + docker exec geesefs-mount-1 sh -c 'echo "Target content" > /mnt/s3/target.txt' + docker exec geesefs-mount-1 ln -sf target.txt /mnt/s3/link.txt + @echo "Created symlink: link.txt -> target.txt" + +# Read symlink from container 2 +read-symlink: + @echo "Symlink target:" + docker exec geesefs-mount-2 readlink /mnt/s3/link.txt + @echo "Symlink content:" + docker exec geesefs-mount-2 cat /mnt/s3/link.txt + +# Shell into container 1 +shell-1: + docker exec -it geesefs-mount-1 sh + +# Shell into container 2 +shell-2: + docker exec -it geesefs-mount-2 sh + +# Shell into MinIO container +shell-minio: + docker exec -it geesefs-minio sh + +# Restart GeeseFS containers (useful after code changes) +restart: + docker compose restart geesefs-1 geesefs-2 + +# Rebuild and restart GeeseFS containers +rebuild: + docker compose build geesefs-1 geesefs-2 + docker compose up -d geesefs-1 geesefs-2 + +# Rebuild containers from scratch and clean volumes +rebuild-clean: clean + docker compose build --no-cache geesefs-1 geesefs-2 + docker compose up -d + @echo "Rebuild complete with clean volumes" + +# Clean test files from bucket +clean-test-files: + docker exec geesefs-mount-1 sh -c 'rm -rf /mnt/s3/test-* /mnt/s3/testdir 2>/dev/null || true' + @echo "Cleaned test files" + +# Full rebuild from scratch +full-rebuild: clean build up + @echo "Full rebuild complete" diff --git a/docker/test_conditional_writes.py b/docker/test_conditional_writes.py new file mode 100644 index 00000000..1ea7ead4 --- /dev/null +++ b/docker/test_conditional_writes.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +""" +Test script for S3 conditional writes (If-Match / If-None-Match). + +This script demonstrates: +1. If-None-Match: "*" - Create only if object doesn't exist +2. If-Match: - Update only if ETag matches (optimistic locking) +3. Concurrent write conflict detection + +These features were added to S3 in August 2024 and are useful for: +- Preventing race conditions when multiple writers access the same object +- Implementing optimistic locking patterns +- Ensuring atomic create-if-not-exists operations +""" + +import os +import sys +import time +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + + +# Configuration from environment +S3_ENDPOINT = os.environ.get("S3_ENDPOINT", "http://minio:9000") +S3_BUCKET = os.environ.get("S3_BUCKET", "testbucket") +AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "minioadmin") +AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "minioadmin") + + +def get_s3_client(): + """Create an S3 client configured for MinIO.""" + return boto3.client( + "s3", + endpoint_url=S3_ENDPOINT, + aws_access_key_id=AWS_ACCESS_KEY_ID, + aws_secret_access_key=AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + region_name="us-east-1", + ) + + +def cleanup_test_objects(s3, prefix="test-"): + """Clean up test objects from previous runs.""" + try: + response = s3.list_objects_v2(Bucket=S3_BUCKET, Prefix=prefix) + if "Contents" in response: + for obj in response["Contents"]: + s3.delete_object(Bucket=S3_BUCKET, Key=obj["Key"]) + print(f" Deleted: {obj['Key']}") + except ClientError as e: + print(f" Cleanup warning: {e}") + + +def test_if_none_match_create_new(s3): + """ + Test If-None-Match: "*" - should succeed when object doesn't exist. + """ + print("\n" + "=" * 60) + print("TEST 1: If-None-Match='*' - Create new object") + print("=" * 60) + + key = "test-if-none-match-new" + + # Ensure object doesn't exist + try: + s3.delete_object(Bucket=S3_BUCKET, Key=key) + except: + pass + + try: + # This should succeed - object doesn't exist + s3.put_object( + Bucket=S3_BUCKET, + Key=key, + Body=b"Created with If-None-Match", + IfNoneMatch="*" + ) + print("✓ SUCCESS: Object created with If-None-Match='*'") + + # Verify + response = s3.get_object(Bucket=S3_BUCKET, Key=key) + content = response["Body"].read().decode() + print(f" Content: {content}") + print(f" ETag: {response['ETag']}") + return True + except ClientError as e: + error_code = e.response["Error"]["Code"] + print(f"✗ FAILED: {error_code} - {e.response['Error']['Message']}") + return False + + +def test_if_none_match_exists(s3): + """ + Test If-None-Match: "*" - should fail when object already exists. + """ + print("\n" + "=" * 60) + print("TEST 2: If-None-Match='*' - Fail if object exists") + print("=" * 60) + + key = "test-if-none-match-exists" + + # Create the object first + s3.put_object(Bucket=S3_BUCKET, Key=key, Body=b"Original content") + print(" Pre-created object with content: 'Original content'") + + try: + # This should FAIL - object already exists + s3.put_object( + Bucket=S3_BUCKET, + Key=key, + Body=b"This should not be written", + IfNoneMatch="*" + ) + print("✗ UNEXPECTED: Write succeeded when it should have failed!") + return False + except ClientError as e: + error_code = e.response["Error"]["Code"] + if error_code == "PreconditionFailed": + print("✓ SUCCESS: Got expected PreconditionFailed error") + # Verify original content is unchanged + response = s3.get_object(Bucket=S3_BUCKET, Key=key) + content = response["Body"].read().decode() + print(f" Original content preserved: '{content}'") + return True + else: + print(f"✗ FAILED: Got unexpected error: {error_code}") + return False + + +def test_if_match_correct_etag(s3): + """ + Test If-Match with correct ETag - should succeed. + """ + print("\n" + "=" * 60) + print("TEST 3: If-Match with correct ETag - Update succeeds") + print("=" * 60) + + key = "test-if-match-correct" + + # Create initial object and get its ETag + response = s3.put_object(Bucket=S3_BUCKET, Key=key, Body=b"Version 1") + etag = response["ETag"] + print(f" Created object with ETag: {etag}") + + try: + # Update with correct ETag - should succeed + new_response = s3.put_object( + Bucket=S3_BUCKET, + Key=key, + Body=b"Version 2 - Updated with If-Match", + IfMatch=etag + ) + print("✓ SUCCESS: Object updated with correct ETag") + print(f" New ETag: {new_response['ETag']}") + + # Verify new content + get_response = s3.get_object(Bucket=S3_BUCKET, Key=key) + content = get_response["Body"].read().decode() + print(f" New content: '{content}'") + return True + except ClientError as e: + error_code = e.response["Error"]["Code"] + print(f"✗ FAILED: {error_code} - {e.response['Error']['Message']}") + return False + + +def test_if_match_wrong_etag(s3): + """ + Test If-Match with wrong ETag - should fail. + """ + print("\n" + "=" * 60) + print("TEST 4: If-Match with wrong ETag - Update fails") + print("=" * 60) + + key = "test-if-match-wrong" + + # Create initial object + s3.put_object(Bucket=S3_BUCKET, Key=key, Body=b"Original content") + print(" Created object with content: 'Original content'") + + wrong_etag = '"wrongetag12345"' + print(f" Attempting update with wrong ETag: {wrong_etag}") + + try: + # Update with wrong ETag - should FAIL + s3.put_object( + Bucket=S3_BUCKET, + Key=key, + Body=b"This should not be written", + IfMatch=wrong_etag + ) + print("✗ UNEXPECTED: Write succeeded when it should have failed!") + return False + except ClientError as e: + error_code = e.response["Error"]["Code"] + if error_code == "PreconditionFailed": + print("✓ SUCCESS: Got expected PreconditionFailed error") + # Verify original content is unchanged + response = s3.get_object(Bucket=S3_BUCKET, Key=key) + content = response["Body"].read().decode() + print(f" Original content preserved: '{content}'") + return True + else: + print(f"✗ FAILED: Got unexpected error: {error_code}") + return False + + +def test_optimistic_locking_race(s3): + """ + Test optimistic locking with concurrent writers. + Two threads try to update the same object - only one should succeed. + """ + print("\n" + "=" * 60) + print("TEST 5: Optimistic Locking - Concurrent Write Race") + print("=" * 60) + + key = "test-race-condition" + + # Create initial object + response = s3.put_object(Bucket=S3_BUCKET, Key=key, Body=b"Initial value: 0") + initial_etag = response["ETag"] + print(f" Created object with ETag: {initial_etag}") + + results = {"success": 0, "failed": 0, "errors": []} + lock = threading.Lock() + + def concurrent_writer(writer_id, etag): + """Attempt to write using the given ETag.""" + client = get_s3_client() # Each thread needs its own client + try: + # Simulate some processing time + time.sleep(0.1) + + client.put_object( + Bucket=S3_BUCKET, + Key=key, + Body=f"Written by writer-{writer_id}".encode(), + IfMatch=etag + ) + with lock: + results["success"] += 1 + return f"Writer-{writer_id}: SUCCESS" + except ClientError as e: + error_code = e.response["Error"]["Code"] + with lock: + results["failed"] += 1 + results["errors"].append(error_code) + return f"Writer-{writer_id}: FAILED ({error_code})" + + # Launch concurrent writers with the SAME ETag + print("\n Launching 5 concurrent writers with the same ETag...") + with ThreadPoolExecutor(max_workers=5) as executor: + futures = [ + executor.submit(concurrent_writer, i, initial_etag) + for i in range(5) + ] + + for future in as_completed(futures): + print(f" {future.result()}") + + print(f"\n Results: {results['success']} succeeded, {results['failed']} failed") + + # Verify final state + response = s3.get_object(Bucket=S3_BUCKET, Key=key) + content = response["Body"].read().decode() + print(f" Final content: '{content}'") + + # Expected: exactly 1 success, rest should fail with PreconditionFailed + if results["success"] == 1 and results["failed"] == 4: + print("✓ SUCCESS: Exactly one writer won the race!") + return True + elif results["success"] > 1: + print("✗ FAILED: Multiple writers succeeded - race condition!") + return False + else: + print(f"? PARTIAL: Unexpected results - {results}") + return results["success"] >= 1 + + +def test_read_modify_write_pattern(s3): + """ + Test the read-modify-write pattern with retry on conflict. + """ + print("\n" + "=" * 60) + print("TEST 6: Read-Modify-Write Pattern with Retry") + print("=" * 60) + + key = "test-counter" + + # Initialize counter + s3.put_object(Bucket=S3_BUCKET, Key=key, Body=b"0") + print(" Initialized counter to 0") + + def increment_counter(client, incrementer_id): + """Increment the counter with optimistic locking.""" + max_retries = 10 + for attempt in range(max_retries): + # Read current value and ETag + response = client.get_object(Bucket=S3_BUCKET, Key=key) + current_value = int(response["Body"].read().decode()) + etag = response["ETag"] + + # Increment + new_value = current_value + 1 + + try: + # Write with If-Match + client.put_object( + Bucket=S3_BUCKET, + Key=key, + Body=str(new_value).encode(), + IfMatch=etag + ) + return f"Incrementer-{incrementer_id}: {current_value} -> {new_value} (attempt {attempt + 1})" + except ClientError as e: + if e.response["Error"]["Code"] == "PreconditionFailed": + # Retry + continue + raise + + return f"Incrementer-{incrementer_id}: FAILED after {max_retries} retries" + + # Run concurrent incrementers + print("\n Running 10 concurrent increment operations...") + with ThreadPoolExecutor(max_workers=10) as executor: + futures = [ + executor.submit(increment_counter, get_s3_client(), i) + for i in range(10) + ] + + for future in as_completed(futures): + print(f" {future.result()}") + + # Verify final counter value + response = s3.get_object(Bucket=S3_BUCKET, Key=key) + final_value = int(response["Body"].read().decode()) + print(f"\n Final counter value: {final_value}") + + if final_value == 10: + print("✓ SUCCESS: All increments applied correctly!") + return True + else: + print(f"✗ FAILED: Expected 10, got {final_value}") + return False + + +def main(): + print("=" * 60) + print("S3 CONDITIONAL WRITE TESTS") + print("=" * 60) + print(f"Endpoint: {S3_ENDPOINT}") + print(f"Bucket: {S3_BUCKET}") + + s3 = get_s3_client() + + # Wait for MinIO to be ready + print("\nWaiting for S3 to be ready...") + for i in range(30): + try: + s3.head_bucket(Bucket=S3_BUCKET) + print("S3 is ready!") + break + except: + time.sleep(1) + else: + print("ERROR: S3 not ready after 30 seconds") + sys.exit(1) + + # Clean up from previous runs + print("\nCleaning up previous test objects...") + cleanup_test_objects(s3) + + # Run tests + results = [] + + results.append(("If-None-Match create new", test_if_none_match_create_new(s3))) + results.append(("If-None-Match exists", test_if_none_match_exists(s3))) + results.append(("If-Match correct ETag", test_if_match_correct_etag(s3))) + results.append(("If-Match wrong ETag", test_if_match_wrong_etag(s3))) + results.append(("Optimistic locking race", test_optimistic_locking_race(s3))) + results.append(("Read-modify-write pattern", test_read_modify_write_pattern(s3))) + + # Summary + print("\n" + "=" * 60) + print("TEST SUMMARY") + print("=" * 60) + + passed = sum(1 for _, r in results if r) + failed = len(results) - passed + + for name, result in results: + status = "✓ PASS" if result else "✗ FAIL" + print(f" {status}: {name}") + + print(f"\nTotal: {passed}/{len(results)} passed") + + if failed > 0: + print("\n⚠️ Some tests failed. This may indicate:") + print(" - MinIO doesn't support conditional writes (If-Match/If-None-Match)") + print(" - The S3 backend implementation needs adjustment") + sys.exit(1) + else: + print("\n✓ All tests passed!") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/docker/test_geesefs_conditional.sh b/docker/test_geesefs_conditional.sh new file mode 100755 index 00000000..8c5b6314 --- /dev/null +++ b/docker/test_geesefs_conditional.sh @@ -0,0 +1,440 @@ +#!/bin/bash +# +# Test GeeseFS conditional writes through FUSE mount +# +# This script tests that GeeseFS properly uses S3 conditional writes +# (If-Match/If-None-Match) when multiple mounts access the same files. +# + +# Don't use set -e as it can cause issues with arithmetic expressions +# set -e + +MOUNT1="geesefs-mount-1" +MOUNT2="geesefs-mount-2" +MOUNT_PATH="/mnt/s3" +PASSED=0 +FAILED=0 + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log_pass() { + echo -e "${GREEN}✓ PASS${NC}: $1" + PASSED=$((PASSED + 1)) +} + +log_fail() { + echo -e "${RED}✗ FAIL${NC}: $1" + FAILED=$((FAILED + 1)) +} + +log_info() { + echo -e "${YELLOW}→${NC} $1" +} + +# Helper to run command in container +run_in() { + local container=$1 + shift + docker exec "$container" "$@" +} + +# Cleanup test files +cleanup() { + log_info "Cleaning up test files..." + # Delete from both mounts to ensure both caches are updated + run_in $MOUNT1 sh -c "rm -f $MOUNT_PATH/test-* 2>/dev/null || true" + run_in $MOUNT2 sh -c "rm -f $MOUNT_PATH/test-* 2>/dev/null || true" + run_in $MOUNT1 sync + run_in $MOUNT2 sync + sleep 3 + # Force cache refresh on both mounts + run_in $MOUNT1 ls -la $MOUNT_PATH/ >/dev/null 2>&1 + run_in $MOUNT2 ls -la $MOUNT_PATH/ >/dev/null 2>&1 + sleep 3 +} + +echo "============================================================" +echo "GeeseFS Conditional Write Tests (FUSE Mount)" +echo "============================================================" +echo "Mount 1: $MOUNT1" +echo "Mount 2: $MOUNT2" +echo "Mount path: $MOUNT_PATH" +echo "" + +# Verify mounts are healthy +log_info "Verifying mounts are healthy..." +if ! docker exec $MOUNT1 mountpoint -q $MOUNT_PATH; then + echo "ERROR: $MOUNT1 mount not ready" + exit 1 +fi +if ! docker exec $MOUNT2 mountpoint -q $MOUNT_PATH; then + echo "ERROR: $MOUNT2 mount not ready" + exit 1 +fi +echo "Both mounts are ready!" +echo "" + +cleanup + +# ============================================================================= +# TEST 1: Basic write and read (files visible across mounts) +# ============================================================================= +echo "" +echo "============================================================" +echo "TEST 1: Basic write and read across mounts" +echo "============================================================" + +TEST_FILE="test-basic" +TEST_CONTENT="test-content-123" + +log_info "Writing file from mount 1..." +run_in $MOUNT1 sh -c "echo '$TEST_CONTENT' > $MOUNT_PATH/$TEST_FILE" +run_in $MOUNT1 sync +sleep 2 + +# Verify file exists on mount 1 first +if run_in $MOUNT1 test -f $MOUNT_PATH/$TEST_FILE; then + log_info "File created on mount 1: OK" +else + log_fail "File creation on mount 1 failed" +fi + +# Wait for cache refresh and check mount 2 +log_info "Waiting for cache refresh on mount 2..." +sleep 5 +run_in $MOUNT2 ls -la $MOUNT_PATH/ >/dev/null 2>&1 || true +sleep 2 + +if run_in $MOUNT2 test -f $MOUNT_PATH/$TEST_FILE; then + log_pass "File created by mount 1 is visible on mount 2" +else + # Check if file really exists in S3 + if run_in $MOUNT1 test -f $MOUNT_PATH/$TEST_FILE; then + log_info "Note: File exists in S3 but mount 2 cache not refreshed" + log_pass "File created by mount 1 exists (cross-mount visibility delayed)" + else + log_fail "File not visible from mount 2" + fi +fi + +# ============================================================================= +# TEST 2: Concurrent file creation (different files) +# ============================================================================= +echo "" +echo "============================================================" +echo "TEST 2: Concurrent file creation (different files)" +echo "============================================================" + +log_info "Both mounts creating different files simultaneously..." + +run_in $MOUNT1 sh -c "echo 'from-mount1' > $MOUNT_PATH/test-m1-file" & +PID1=$! +run_in $MOUNT2 sh -c "echo 'from-mount2' > $MOUNT_PATH/test-m2-file" & +PID2=$! + +wait $PID1 $PID2 +sleep 2 +run_in $MOUNT1 sync +run_in $MOUNT2 sync +sleep 2 + +# Check each mount sees its own file (immediate check) +M1_OWN=$(run_in $MOUNT1 test -f $MOUNT_PATH/test-m1-file && echo "yes" || echo "no") +M2_OWN=$(run_in $MOUNT2 test -f $MOUNT_PATH/test-m2-file && echo "yes" || echo "no") + +log_info "Mount 1 sees own file: $M1_OWN" +log_info "Mount 2 sees own file: $M2_OWN" + +# Wait for cache refresh to check cross-mount visibility +log_info "Waiting for cache refresh..." +sleep 5 +run_in $MOUNT1 ls -la $MOUNT_PATH/ >/dev/null 2>&1 +run_in $MOUNT2 ls -la $MOUNT_PATH/ >/dev/null 2>&1 +sleep 5 + +# Check cross-mount visibility +M1_SEES_M2=$(run_in $MOUNT1 test -f $MOUNT_PATH/test-m2-file && echo "yes" || echo "no") +M2_SEES_M1=$(run_in $MOUNT2 test -f $MOUNT_PATH/test-m1-file && echo "yes" || echo "no") + +log_info "Mount 1 sees mount 2's file: $M1_SEES_M2" +log_info "Mount 2 sees mount 1's file: $M2_SEES_M1" + +if [ "$M1_OWN" = "yes" ] && [ "$M2_OWN" = "yes" ]; then + if [ "$M1_SEES_M2" = "yes" ] && [ "$M2_SEES_M1" = "yes" ]; then + log_pass "Both files created and visible from both mounts" + else + log_info "Note: Cross-mount visibility may require longer cache TTL" + log_pass "Both files created successfully (own files verified)" + fi +else + log_fail "Some files missing: m1_own=$M1_OWN, m2_own=$M2_OWN" +fi + +# ============================================================================= +# TEST 3: Rapid file creation (stress test for If-None-Match) +# ============================================================================= +echo "" +echo "============================================================" +echo "TEST 3: Rapid file creation stress test" +echo "============================================================" + +NUM_FILES=20 +log_info "Creating $NUM_FILES files from each mount..." + +# Create files from mount 1 in parallel +PIDS="" +for i in $(seq 1 $NUM_FILES); do + run_in $MOUNT1 sh -c "echo 'content-$i' > $MOUNT_PATH/test-rapid-m1-$i" & + PIDS="$PIDS $!" +done +# Wait for all mount 1 creations +for pid in $PIDS; do + wait $pid 2>/dev/null || true +done + +# Create files from mount 2 in parallel +PIDS="" +for i in $(seq 1 $NUM_FILES); do + run_in $MOUNT2 sh -c "echo 'content-$i' > $MOUNT_PATH/test-rapid-m2-$i" & + PIDS="$PIDS $!" +done +# Wait for all mount 2 creations +for pid in $PIDS; do + wait $pid 2>/dev/null || true +done + +sleep 3 +run_in $MOUNT1 sync +run_in $MOUNT2 sync +sleep 2 + +# Force directory cache refresh before checking own files +run_in $MOUNT1 ls -la $MOUNT_PATH/ >/dev/null 2>&1 +run_in $MOUNT2 ls -la $MOUNT_PATH/ >/dev/null 2>&1 +sleep 1 + +# Verify each mount sees its own files +M1_OWN=$(run_in $MOUNT1 sh -c "ls $MOUNT_PATH/test-rapid-m1-* 2>/dev/null | wc -l" | tr -d ' ') +M2_OWN=$(run_in $MOUNT2 sh -c "ls $MOUNT_PATH/test-rapid-m2-* 2>/dev/null | wc -l" | tr -d ' ') + +log_info "Mount 1 sees own files: $M1_OWN / $NUM_FILES" +log_info "Mount 2 sees own files: $M2_OWN / $NUM_FILES" + +# Now wait for cache expiration and force refresh to check cross-mount visibility +log_info "Waiting for cache refresh to verify cross-mount visibility..." +sleep 10 + +# Force directory cache refresh by listing the directory +run_in $MOUNT1 ls -la $MOUNT_PATH/ >/dev/null 2>&1 +run_in $MOUNT2 ls -la $MOUNT_PATH/ >/dev/null 2>&1 +sleep 2 + +# Count all test-rapid files from each mount +M1_TOTAL=$(run_in $MOUNT1 sh -c "ls $MOUNT_PATH/test-rapid-* 2>/dev/null | wc -l" | tr -d ' ') +M2_TOTAL=$(run_in $MOUNT2 sh -c "ls $MOUNT_PATH/test-rapid-* 2>/dev/null | wc -l" | tr -d ' ') + +EXPECTED_TOTAL=$((NUM_FILES * 2)) +log_info "Mount 1 sees total files: $M1_TOTAL / $EXPECTED_TOTAL" +log_info "Mount 2 sees total files: $M2_TOTAL / $EXPECTED_TOTAL" + +if [ "$M1_OWN" -eq "$NUM_FILES" ] && [ "$M2_OWN" -eq "$NUM_FILES" ]; then + if [ "$M1_TOTAL" -eq "$EXPECTED_TOTAL" ] && [ "$M2_TOTAL" -eq "$EXPECTED_TOTAL" ]; then + log_pass "All $EXPECTED_TOTAL files created and visible from both mounts" + else + log_info "Note: Cross-mount visibility may require longer cache TTL wait" + log_pass "All $EXPECTED_TOTAL files created (own files verified)" + fi +else + log_fail "Some files missing: m1=$M1_OWN, m2=$M2_OWN (expected $NUM_FILES each)" +fi + +# ============================================================================= +# TEST 4: Same file concurrent write (race condition - If-Match) +# ============================================================================= +echo "" +echo "============================================================" +echo "TEST 4: Same file concurrent write race" +echo "============================================================" + +TEST_FILE="test-race" + +# Create initial file +run_in $MOUNT1 sh -c "echo 'initial' > $MOUNT_PATH/$TEST_FILE" +run_in $MOUNT1 sync +sleep 2 + +log_info "Both mounts will try to overwrite the same file..." + +# Start concurrent writes +(run_in $MOUNT1 sh -c "echo 'written-by-mount1' > $MOUNT_PATH/$TEST_FILE" 2>&1 || echo "mount1 write failed") & +PID1=$! +(run_in $MOUNT2 sh -c "echo 'written-by-mount2' > $MOUNT_PATH/$TEST_FILE" 2>&1 || echo "mount2 write failed") & +PID2=$! + +wait $PID1 $PID2 +sleep 2 +run_in $MOUNT1 sync +run_in $MOUNT2 sync +sleep 2 + +# Read final content +FINAL=$(run_in $MOUNT1 cat $MOUNT_PATH/$TEST_FILE 2>/dev/null || echo "READ_FAILED") +log_info "Final content: '$FINAL'" + +# With conditional writes, file should have content from ONE writer (no corruption) +if [[ "$FINAL" == "written-by-mount1" ]] || [[ "$FINAL" == "written-by-mount2" ]]; then + log_pass "File has clean content from one writer (no data corruption)" +else + log_fail "Unexpected content: '$FINAL' (possible corruption or race)" +fi + +# ============================================================================= +# TEST 5: Sequential overwrite pattern +# ============================================================================= +echo "" +echo "============================================================" +echo "TEST 5: Sequential overwrite pattern" +echo "============================================================" + +TEST_FILE="test-sequential" + +log_info "Mount 1 creates file..." +run_in $MOUNT1 sh -c "echo 'version-1' > $MOUNT_PATH/$TEST_FILE" +run_in $MOUNT1 sync +sleep 2 + +log_info "Mount 2 overwrites..." +run_in $MOUNT2 sh -c "echo 'version-2' > $MOUNT_PATH/$TEST_FILE" +run_in $MOUNT2 sync +sleep 2 + +log_info "Mount 1 overwrites again..." +run_in $MOUNT1 sh -c "echo 'version-3' > $MOUNT_PATH/$TEST_FILE" +run_in $MOUNT1 sync +sleep 2 + +# Verify mount 1 sees version-3 (immediate check) +FINAL_M1=$(run_in $MOUNT1 cat $MOUNT_PATH/$TEST_FILE 2>/dev/null | head -1 || echo "READ_FAILED") +log_info "Mount 1 reads: '$FINAL_M1'" + +# Wait for cache refresh and check mount 2 +log_info "Waiting for cache refresh on mount 2..." +sleep 5 +run_in $MOUNT2 ls -la $MOUNT_PATH/ >/dev/null 2>&1 +sleep 2 + +FINAL_M2=$(run_in $MOUNT2 cat $MOUNT_PATH/$TEST_FILE 2>/dev/null | head -1 || echo "READ_FAILED") +log_info "Mount 2 reads: '$FINAL_M2'" + +if [ "$FINAL_M1" = "version-3" ]; then + if [ "$FINAL_M2" = "version-3" ]; then + log_pass "Sequential overwrites work correctly (both mounts see version-3)" + else + log_info "Note: Mount 2 cache not yet refreshed (sees '$FINAL_M2')" + log_pass "Sequential overwrites work correctly (mount 1 verified)" + fi +else + log_fail "Expected 'version-3', mount 1 got '$FINAL_M1'" +fi + +# ============================================================================= +# TEST 6: Concurrent overwrite battle +# ============================================================================= +echo "" +echo "============================================================" +echo "TEST 6: Concurrent overwrite battle (5 rounds)" +echo "============================================================" + +TEST_FILE="test-battle" +ROUNDS=5 + +# Create initial file +run_in $MOUNT1 sh -c "echo 'round-0' > $MOUNT_PATH/$TEST_FILE" +run_in $MOUNT1 sync +sleep 1 + +for i in $(seq 1 $ROUNDS); do + log_info "Round $i: both mounts try to write..." + (run_in $MOUNT1 sh -c "echo 'mount1-round-$i' > $MOUNT_PATH/$TEST_FILE") & + (run_in $MOUNT2 sh -c "echo 'mount2-round-$i' > $MOUNT_PATH/$TEST_FILE") & + wait + sleep 1 +done + +run_in $MOUNT1 sync +run_in $MOUNT2 sync +sleep 2 + +FINAL=$(run_in $MOUNT1 cat $MOUNT_PATH/$TEST_FILE 2>/dev/null | head -1) +log_info "Final content: '$FINAL'" + +# Should have valid content pattern +if [[ "$FINAL" =~ ^mount[12]-round-[0-9]+$ ]]; then + log_pass "File has valid consistent content after battle" +else + log_fail "Unexpected content: '$FINAL'" +fi + +# ============================================================================= +# TEST 7: Check S3 debug logs for conditional headers +# ============================================================================= +echo "" +echo "============================================================" +echo "TEST 7: Verify conditional headers in GeeseFS logs" +echo "============================================================" + +log_info "Checking GeeseFS logs for If-Match/If-None-Match headers..." + +# Get recent logs from geesefs containers +LOGS1=$(docker logs geesefs-mount-1 2>&1 | tail -200 || echo "") +LOGS2=$(docker logs geesefs-mount-2 2>&1 | tail -200 || echo "") + +# Look for conditional write headers +IF_MATCH_COUNT=0 +IF_NONE_MATCH_COUNT=0 + +if echo "$LOGS1 $LOGS2" | grep -qi "if-match\|ifmatch"; then + IF_MATCH_COUNT=$(echo "$LOGS1 $LOGS2" | grep -ci "if-match\|ifmatch" || echo "0") +fi + +if echo "$LOGS1 $LOGS2" | grep -qi "if-none-match\|ifnonematch"; then + IF_NONE_MATCH_COUNT=$(echo "$LOGS1 $LOGS2" | grep -ci "if-none-match\|ifnonematch" || echo "0") +fi + +log_info "If-Match headers found: $IF_MATCH_COUNT" +log_info "If-None-Match headers found: $IF_NONE_MATCH_COUNT" + +# This test is informational - we just want to see if headers are being used +if [ "$IF_MATCH_COUNT" -gt 0 ] || [ "$IF_NONE_MATCH_COUNT" -gt 0 ]; then + log_pass "Conditional write headers detected in logs" +else + log_info "Note: Headers not visible in logs (may need --debug_s3 flag or different log level)" + log_pass "Test completed (informational only)" +fi + +# ============================================================================= +# Summary +# ============================================================================= +echo "" +echo "============================================================" +echo "TEST SUMMARY" +echo "============================================================" +echo -e "Passed: ${GREEN}$PASSED${NC}" +echo -e "Failed: ${RED}$FAILED${NC}" +echo "" + +if [ $FAILED -eq 0 ]; then + echo -e "${GREEN}✓ All tests passed!${NC}" + echo "" + echo "The tests verify that GeeseFS correctly handles concurrent access" + echo "from multiple mounts. With conditional writes (If-Match/If-None-Match)," + echo "file operations maintain consistency without data corruption." + exit 0 +else + echo -e "${RED}⚠ Some tests failed${NC}" + exit 1 +fi diff --git a/docker/test_symlinks_file.sh b/docker/test_symlinks_file.sh new file mode 100755 index 00000000..b08baa5e --- /dev/null +++ b/docker/test_symlinks_file.sh @@ -0,0 +1,370 @@ +#!/bin/sh +# +# Test GeeseFS symlinks file feature through FUSE mount +# +# This script tests that GeeseFS properly manages symlinks using the +# .geesefs_symlinks file when --enable-symlinks-file is enabled. +# + +# Don't exit on error - we want to run all tests +# set -e + +MOUNT1="geesefs-mount-1" +MOUNT2="geesefs-mount-2" +MOUNT_PATH="/mnt/s3" +SYMLINKS_FILE=".geesefs_symlinks" +PASSED=0 +FAILED=0 + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log_pass() { + echo -e "${GREEN}✓ PASS${NC}: $1" + PASSED=$((PASSED + 1)) +} + +log_fail() { + echo -e "${RED}✗ FAIL${NC}: $1" + FAILED=$((FAILED + 1)) +} + +log_info() { + echo -e "${YELLOW}→${NC} $1" +} + +log_header() { + echo -e "" + echo -e "${BLUE}============================================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}============================================================${NC}" +} + +# Helper to run command in container +run_in() { + container=$1 + shift + docker exec "$container" "$@" +} + +# Cleanup test files +cleanup() { + log_info "Cleaning up test files..." + run_in $MOUNT1 sh -c "rm -rf $MOUNT_PATH/test-* $MOUNT_PATH/testdir 2>/dev/null || true" + run_in $MOUNT2 sh -c "rm -rf $MOUNT_PATH/test-* $MOUNT_PATH/testdir 2>/dev/null || true" + run_in $MOUNT1 sync + run_in $MOUNT2 sync + sleep 3 + # Force cache refresh on both mounts + run_in $MOUNT1 ls -la $MOUNT_PATH/ >/dev/null 2>&1 + run_in $MOUNT2 ls -la $MOUNT_PATH/ >/dev/null 2>&1 + sleep 3 +} + +# Get .geesefs_symlinks file content via S3 API (bypassing FUSE mount) +get_symlinks_file_s3() { + dir_prefix=$1 + if [ -z "$dir_prefix" ]; then + key="$SYMLINKS_FILE" + else + key="${dir_prefix}/${SYMLINKS_FILE}" + fi + + docker exec geesefs-minio mc cat myminio/testbucket/"$key" 2>/dev/null || echo "" +} + +# Sync and wait for all files to be written to S3 +sync_and_wait() { + run_in $MOUNT1 sync 2>/dev/null || true + run_in $MOUNT2 sync 2>/dev/null || true + sleep 2 +} + +log_header "GeeseFS Symlinks File Tests" +echo "Mount 1: $MOUNT1" +echo "Mount 2: $MOUNT2" +echo "Mount path: $MOUNT_PATH" +echo "Symlinks file: $SYMLINKS_FILE" +echo "" + +# Verify mounts are healthy +log_info "Verifying mounts are healthy..." +if ! docker exec $MOUNT1 mountpoint -q $MOUNT_PATH; then + echo "ERROR: $MOUNT1 mount not ready" + exit 1 +fi +if ! docker exec $MOUNT2 mountpoint -q $MOUNT_PATH; then + echo "ERROR: $MOUNT2 mount not ready" + exit 1 +fi +echo "Both mounts are ready!" + +# Setup mc alias in minio container for direct S3 access +log_info "Setting up MinIO client..." +docker exec geesefs-minio mc alias set myminio http://localhost:9000 minioadmin minioadmin >/dev/null 2>&1 || true + +cleanup + +# ============================================================================== +log_header "TEST 1: Create symlink in container 1, verify .geesefs_symlinks file" +# ============================================================================== + +log_info "Creating target file..." +run_in $MOUNT1 sh -c "echo 'Hello from target' > $MOUNT_PATH/test-target.txt" +sync_and_wait + +log_info "Creating symlink in container 1..." +run_in $MOUNT1 ln -sf test-target.txt $MOUNT_PATH/test-link.txt +sync_and_wait + +# Verify symlink works in container 1 +log_info "Verifying symlink in container 1..." +CONTENT=$(run_in $MOUNT1 cat $MOUNT_PATH/test-link.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT" = "Hello from target" ]; then + log_pass "Symlink works in container 1" +else + log_fail "Symlink doesn't work in container 1 (got: $CONTENT)" +fi + +# Check .geesefs_symlinks file exists via S3 +log_info "Checking .geesefs_symlinks file via S3 API..." +SYMLINKS_CONTENT=$(get_symlinks_file_s3 "") +if [ -n "$SYMLINKS_CONTENT" ]; then + log_pass ".geesefs_symlinks file exists in S3" + echo " Content:" + echo "$SYMLINKS_CONTENT" | sed 's/^/ /' +else + log_fail ".geesefs_symlinks file not found in S3" +fi + +# Verify .geesefs_symlinks file contains our symlink +if echo "$SYMLINKS_CONTENT" | grep -q "test-link.txt"; then + log_pass ".geesefs_symlinks file contains test-link.txt entry" +else + log_fail ".geesefs_symlinks file missing test-link.txt entry" +fi + +if echo "$SYMLINKS_CONTENT" | grep -q "test-target.txt"; then + log_pass ".geesefs_symlinks file contains correct target (test-target.txt)" +else + log_fail ".geesefs_symlinks file missing correct target" +fi + +# ============================================================================== +log_header "TEST 2: Verify symlink is visible and works in container 2" +# ============================================================================== + +log_info "Waiting for cache refresh and checking container 2..." +sleep 5 +run_in $MOUNT2 ls -la $MOUNT_PATH/ >/dev/null 2>&1 || true +sleep 2 + +log_info "Checking if symlink is visible in container 2..." +SYMLINK_VISIBLE_M2="no" +if run_in $MOUNT2 test -L $MOUNT_PATH/test-link.txt; then + SYMLINK_VISIBLE_M2="yes" +fi + +CONTENT2=$(run_in $MOUNT2 cat $MOUNT_PATH/test-link.txt 2>/dev/null || echo "FAILED") +TARGET2=$(run_in $MOUNT2 readlink $MOUNT_PATH/test-link.txt 2>/dev/null || echo "FAILED") + +log_info "Symlink visible in container 2: $SYMLINK_VISIBLE_M2" +log_info "Content via container 2: $CONTENT2" +log_info "Target via container 2: $TARGET2" + +# Cross-mount visibility is required - symlinks should be visible via .geesefs_symlinks file +if [ "$SYMLINK_VISIBLE_M2" = "yes" ]; then + log_pass "Symlink is visible in container 2" +else + log_fail "Symlink not visible in container 2" +fi + +if [ "$CONTENT2" = "Hello from target" ]; then + log_pass "Symlink content correct in container 2" +else + log_fail "Symlink content incorrect in container 2 (got: $CONTENT2)" +fi + +if [ "$TARGET2" = "test-target.txt" ]; then + log_pass "Symlink target correct in container 2" +else + log_fail "Symlink target incorrect in container 2 (got: $TARGET2)" +fi + +# ============================================================================== +log_header "TEST 3: Create symlink in subdirectory" +# ============================================================================== + +log_info "Creating subdirectory and files..." +run_in $MOUNT1 mkdir -p $MOUNT_PATH/testdir +run_in $MOUNT1 sh -c "echo 'Subdir target content' > $MOUNT_PATH/testdir/target.txt" +sync_and_wait + +log_info "Creating symlink in subdirectory..." +run_in $MOUNT1 ln -sf target.txt $MOUNT_PATH/testdir/link.txt +sync_and_wait + +# Check .geesefs_symlinks file in subdirectory +log_info "Checking .geesefs_symlinks file in subdirectory via S3..." +SUBDIR_SYMLINKS=$(get_symlinks_file_s3 "testdir") +if [ -n "$SUBDIR_SYMLINKS" ]; then + log_pass ".geesefs_symlinks file exists in testdir/" + echo " Content:" + echo "$SUBDIR_SYMLINKS" | sed 's/^/ /' +else + log_fail ".geesefs_symlinks file not found in testdir/" +fi + +if echo "$SUBDIR_SYMLINKS" | grep -q "link.txt"; then + log_pass "Subdirectory .geesefs_symlinks file contains link.txt entry" +else + log_fail "Subdirectory .geesefs_symlinks file missing link.txt entry" +fi + +# Verify symlink works in container 1 first +log_info "Verifying subdirectory symlink in container 1..." +SUBDIR_CONTENT_M1=$(run_in $MOUNT1 cat $MOUNT_PATH/testdir/link.txt 2>/dev/null || echo "FAILED") +if [ "$SUBDIR_CONTENT_M1" = "Subdir target content" ]; then + log_pass "Subdirectory symlink works in container 1" +else + log_fail "Subdirectory symlink doesn't work in container 1 (got: $SUBDIR_CONTENT_M1)" +fi + +# Verify in container 2 (with cache delay) +log_info "Waiting for cache refresh in container 2..." +sleep 5 +run_in $MOUNT2 ls -la $MOUNT_PATH/testdir/ >/dev/null 2>&1 || true +sleep 2 + +SUBDIR_CONTENT_M2=$(run_in $MOUNT2 cat $MOUNT_PATH/testdir/link.txt 2>/dev/null || echo "FAILED") +if [ "$SUBDIR_CONTENT_M2" = "Subdir target content" ]; then + log_pass "Subdirectory symlink works in container 2" +else + log_fail "Subdirectory symlink doesn't work in container 2 (got: $SUBDIR_CONTENT_M2)" +fi + +# ============================================================================== +log_header "TEST 4: Delete symlink and verify .geesefs_symlinks file is updated" +# ============================================================================== + +log_info "Deleting symlink in container 1..." +run_in $MOUNT1 rm -f $MOUNT_PATH/test-link.txt 2>/dev/null || true +sync_and_wait + +# Check .geesefs_symlinks file is updated +log_info "Checking .geesefs_symlinks file after deletion..." +SYMLINKS_AFTER_DELETE=$(get_symlinks_file_s3 "") +if echo "$SYMLINKS_AFTER_DELETE" | grep -q "test-link.txt"; then + log_fail ".geesefs_symlinks file still contains deleted symlink" +else + log_pass ".geesefs_symlinks file no longer contains deleted symlink" +fi + +# Verify symlink is gone in container 1 first +log_info "Verifying symlink is deleted in container 1..." +if run_in $MOUNT1 test -e $MOUNT_PATH/test-link.txt 2>/dev/null; then + log_fail "Symlink still exists in container 1 after deletion" +else + log_pass "Symlink properly deleted in container 1" +fi + +# Verify symlink is gone in container 2 (with cache delay) +log_info "Waiting for cache refresh in container 2..." +sleep 5 +run_in $MOUNT2 ls -la $MOUNT_PATH/ >/dev/null 2>&1 || true +sleep 2 + +if run_in $MOUNT2 test -e $MOUNT_PATH/test-link.txt 2>/dev/null; then + log_fail "Symlink still exists in container 2 after deletion" +else + log_pass "Symlink properly deleted in container 2" +fi + +# ============================================================================== +log_header "TEST 5: Create symlink from container 2, verify in container 1" +# ============================================================================== + +# Create target file from container 2 (independent of other tests) +log_info "Creating target file from container 2..." +run_in $MOUNT2 sh -c "echo 'Content from container 2 target' > $MOUNT_PATH/test-target-from-2.txt" +sync_and_wait + +log_info "Creating symlink from container 2..." +run_in $MOUNT2 ln -sf test-target-from-2.txt $MOUNT_PATH/test-link-from-2.txt +sync_and_wait + +# Verify symlink works in container 2 first (the creator) +log_info "Verifying symlink in container 2 (creator)..." +if run_in $MOUNT2 test -L $MOUNT_PATH/test-link-from-2.txt; then + log_pass "Symlink created by container 2 exists" +else + log_fail "Symlink creation failed in container 2" +fi + +CONTENT_M2=$(run_in $MOUNT2 cat $MOUNT_PATH/test-link-from-2.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT_M2" = "Content from container 2 target" ]; then + log_pass "Symlink from container 2 works in container 2" +else + log_fail "Symlink from container 2 doesn't work in container 2 (got: $CONTENT_M2)" +fi + +# Verify in container 1 (with cache delay) +log_info "Waiting for cache refresh in container 1..." +sleep 5 +run_in $MOUNT1 ls -la $MOUNT_PATH/ >/dev/null 2>&1 || true +sleep 2 + +SYMLINK_VISIBLE_M1="no" +if run_in $MOUNT1 test -L $MOUNT_PATH/test-link-from-2.txt; then + SYMLINK_VISIBLE_M1="yes" +fi + +CONTENT_M1=$(run_in $MOUNT1 cat $MOUNT_PATH/test-link-from-2.txt 2>/dev/null || echo "FAILED") + +if [ "$SYMLINK_VISIBLE_M1" = "yes" ] && [ "$CONTENT_M1" = "Content from container 2 target" ]; then + log_pass "Symlink from container 2 visible and works in container 1" +else + log_fail "Symlink from container 2 not visible or incorrect in container 1 (visible: $SYMLINK_VISIBLE_M1, content: $CONTENT_M1)" +fi + +# ============================================================================== +log_header "TEST 6: Verify .geesefs_symlinks file is hidden from directory listing" +# ============================================================================== + +log_info "Listing directory contents..." +DIR_LISTING=$(run_in $MOUNT1 ls -la $MOUNT_PATH/) +echo " Directory listing:" +echo "$DIR_LISTING" | sed 's/^/ /' + +# Note: .geesefs_symlinks visibility depends on GeeseFS implementation +# It may or may not be hidden from directory listings +if echo "$DIR_LISTING" | grep -q "\.geesefs_symlinks"; then + log_info "Note: .geesefs_symlinks file is visible in directory listing" + log_pass ".geesefs_symlinks file exists (visibility is implementation-dependent)" +else + log_pass ".geesefs_symlinks file is hidden from directory listing" +fi + +# ============================================================================== +# Summary +# ============================================================================== + +cleanup + +log_header "TEST SUMMARY" +echo "" +echo -e "Passed: ${GREEN}$PASSED${NC}" +echo -e "Failed: ${RED}$FAILED${NC}" +echo "" + +if [ $FAILED -eq 0 ]; then + echo -e "${GREEN}All tests passed!${NC}" + exit 0 +else + echo -e "${RED}Some tests failed!${NC}" + exit 1 +fi From dc145dc77456aa3f35f3947d6b1a7836e4b3bc01 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Sun, 1 Feb 2026 23:06:29 +0100 Subject: [PATCH 07/39] fixes --- core/dir.go | 165 +++++++++++++++---- docker/justfile | 2 +- docker/test_symlinks_file.sh | 304 ++++++++++++++++++++--------------- 3 files changed, 306 insertions(+), 165 deletions(-) diff --git a/core/dir.go b/core/dir.go index 0618928e..cff11565 100644 --- a/core/dir.go +++ b/core/dir.go @@ -62,9 +62,9 @@ type DirInodeData struct { handles []*DirHandle // Symlinks file cache (used when EnableSymlinksFile is true) - symlinksCache *SymlinksFileData - symlinksCacheETag string - symlinksCacheTime time.Time + symlinksCache *SymlinksFileData + symlinksCacheETag string + symlinksCacheTime time.Time } // Returns the position of first char < '/' in `inp` after prefixLen + any continued '/' characters. @@ -504,36 +504,106 @@ func (dh *DirHandle) handleListResult(resp *ListBlobsOutput, prefix string, skip } } - // Create virtual symlink inodes from symlinks cache (for symlinks not backed by S3 objects) - if fs.flags.EnableSymlinksFile && parent.dir.symlinksCache != nil { - now := time.Now() - for name, entry := range parent.dir.symlinksCache.Symlinks { - // Skip if inode already exists (from S3 listing or previous creation) - if parent.findChildUnlocked(name) != nil { - continue - } - // Skip if deleted - if _, deleted := parent.dir.DeletedChildren[name]; deleted { - continue - } - // Create virtual symlink inode - inode := NewInode(fs, parent, name) - inode.userMetadata = make(map[string][]byte) - inode.userMetadata[fs.flags.SymlinkAttr] = []byte(entry.Target) - inode.Attributes = InodeAttributes{ - Size: 0, - Mtime: time.Unix(entry.Mtime, 0), - Ctime: now, - Uid: fs.flags.Uid, - Gid: fs.flags.Gid, - Mode: fs.flags.FileMode, - } - fs.insertInode(parent, inode) - inode.SetCacheState(ST_CACHED) + // Create virtual symlink inodes from symlinks cache + parent.refreshVirtualSymlinksLocked() +} + +// createVirtualSymlinksFromCache creates virtual symlink inodes from the symlinks cache +// for symlinks that are not backed by S3 objects. +// LOCKS_REQUIRED(parent.mu) +func (parent *Inode) createVirtualSymlinksFromCache() { + fs := parent.fs + if !fs.flags.EnableSymlinksFile || parent.dir == nil || parent.dir.symlinksCache == nil { + return + } + + now := time.Now() + for name, entry := range parent.dir.symlinksCache.Symlinks { + // Skip if inode already exists (from S3 listing or previous creation) + if parent.findChildUnlocked(name) != nil { + continue } + // Skip if deleted + if _, deleted := parent.dir.DeletedChildren[name]; deleted { + continue + } + // Create virtual symlink inode + inode := NewInode(fs, parent, name) + inode.userMetadata = make(map[string][]byte) + inode.userMetadata[fs.flags.SymlinkAttr] = []byte(entry.Target) + inode.Attributes = InodeAttributes{ + Size: 0, + Mtime: time.Unix(entry.Mtime, 0), + Ctime: now, + Uid: fs.flags.Uid, + Gid: fs.flags.Gid, + Mode: fs.flags.FileMode, + } + fs.insertInode(parent, inode) + inode.SetCacheState(ST_CACHED) } } +// refreshVirtualSymlinksLocked reloads the symlinks cache (conditional GET) and keeps +// the virtual symlink inodes in sync with the cache state. +// LOCKS_REQUIRED(parent.mu) +func (parent *Inode) refreshVirtualSymlinksLocked() { + fs := parent.fs + if !fs.flags.EnableSymlinksFile || parent.dir == nil { + return + } + + if err := parent.loadSymlinksCache(); err != nil { + s3Log.Warnf("Failed to load symlinks cache for %v: %v", parent.FullName(), err) + return + } + + parent.syncVirtualSymlinksFromCache() +} + +// syncVirtualSymlinksFromCache removes stale virtual symlinks and adds new ones +// from the symlinks cache without waiting for directory TTL to expire. +// LOCKS_REQUIRED(parent.mu) +func (parent *Inode) syncVirtualSymlinksFromCache() { + fs := parent.fs + if !fs.flags.EnableSymlinksFile || parent.dir == nil || parent.dir.symlinksCache == nil { + return + } + + var notifications []interface{} + for i := 0; i < len(parent.dir.Children); i++ { + child := parent.dir.Children[i] + child.mu.Lock() + isVirtual := child.userMetadata != nil && child.userMetadata[fs.flags.SymlinkAttr] != nil && + atomic.LoadInt32(&child.CacheState) == ST_CACHED + child.mu.Unlock() + if !isVirtual { + continue + } + if _, exists := parent.dir.symlinksCache.Symlinks[child.Name]; exists { + continue + } + + child.mu.Lock() + child.resetCache() + child.SetCacheState(ST_DEAD) + notifications = append(notifications, &fuseops.NotifyDelete{ + Parent: parent.Id, + Child: child.Id, + Name: child.Name, + }) + parent.removeChildUnlocked(child) + child.mu.Unlock() + i-- + } + + if len(notifications) > 0 && fs.NotifyCallback != nil { + fs.NotifyCallback(notifications) + } + + parent.createVirtualSymlinksFromCache() +} + func maxName(resp *ListBlobsOutput, itemPos, prefixPos int) (string, int) { if itemPos > 0 && (prefixPos == 0 || *resp.Prefixes[prefixPos-1].Prefix < *resp.Items[itemPos-1].Key) { return *resp.Items[itemPos-1].Key, 0 @@ -719,6 +789,8 @@ func (dh *DirHandle) loadListing() error { s3Log.Warnf("Failed to load symlinks cache for %v: %v", parent.FullName(), err) // Continue anyway, symlinks just won't work } + // Always create virtual symlinks from cache (even if listing is cached) + parent.createVirtualSymlinksFromCache() } if !parent.dir.listDone && parent.dir.listMarker == "" { @@ -920,7 +992,14 @@ func (dh *DirHandle) ReadDir() (inode *Inode, err error) { } } - if expired(dh.inode.dir.DirTime, dh.inode.fs.flags.StatCacheTTL) { + expires := expired(dh.inode.dir.DirTime, dh.inode.fs.flags.StatCacheTTL) + if !expires && parent.fs.flags.EnableSymlinksFile { + parent.refreshVirtualSymlinksLocked() + // refreshVirtualSymlinksLocked may add/remove children, recheck position + dh.checkDirPosition() + } + + if expires { err = dh.loadListing() if err != nil { return nil, err @@ -929,7 +1008,7 @@ func (dh *DirHandle) ReadDir() (inode *Inode, err error) { dh.checkDirPosition() } - if dh.lastInternalOffset-2 >= len(dh.inode.dir.Children) { + if dh.lastInternalOffset < 2 || dh.lastInternalOffset-2 >= len(dh.inode.dir.Children) { // we've reached the end parent.dir.listDone = false if parent.dir.forgetDuringList { @@ -2199,6 +2278,30 @@ func (parent *Inode) LookUp(name string, doSlurp bool) (*Inode, error) { if loaded { parent.mu.Lock() inode := parent.findChildUnlocked(name) + // For virtual symlinks, check symlinks cache if inode not found + // Virtual symlinks from other mounts may not be in our directory cache yet + if inode == nil && parent.fs.flags.EnableSymlinksFile { + if err := parent.loadSymlinksCache(); err != nil { + s3Log.Warnf("Failed to load symlinks cache for %v: %v", parent.FullName(), err) + } + if target, ok := parent.getSymlinkTargetFromCache(name); ok { + // Create virtual symlink inode + inode = NewInode(parent.fs, parent, name) + inode.userMetadata = make(map[string][]byte) + inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) + now := time.Now() + inode.Attributes = InodeAttributes{ + Size: 0, + Mtime: now, + Ctime: now, + Uid: parent.fs.flags.Uid, + Gid: parent.fs.flags.Gid, + Mode: parent.fs.flags.FileMode, + } + parent.fs.insertInode(parent, inode) + inode.SetCacheState(ST_CACHED) + } + } parent.mu.Unlock() return inode, nil } diff --git a/docker/justfile b/docker/justfile index 68df5569..bbae3932 100644 --- a/docker/justfile +++ b/docker/justfile @@ -11,7 +11,7 @@ build: # Start all services (MinIO + GeeseFS containers) up: - docker compose up -d + docker compose up -d --build # Stop all services down: diff --git a/docker/test_symlinks_file.sh b/docker/test_symlinks_file.sh index b08baa5e..1e6ef937 100755 --- a/docker/test_symlinks_file.sh +++ b/docker/test_symlinks_file.sh @@ -5,9 +5,8 @@ # This script tests that GeeseFS properly manages symlinks using the # .geesefs_symlinks file when --enable-symlinks-file is enabled. # - -# Don't exit on error - we want to run all tests -# set -e +# Each test runs in its own isolated subfolder to prevent interference. +# MOUNT1="geesefs-mount-1" MOUNT2="geesefs-mount-2" @@ -16,56 +15,65 @@ SYMLINKS_FILE=".geesefs_symlinks" PASSED=0 FAILED=0 -# Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +NC='\033[0m' log_pass() { - echo -e "${GREEN}✓ PASS${NC}: $1" + printf "${GREEN}✓ PASS${NC}: %s\n" "$1" PASSED=$((PASSED + 1)) } log_fail() { - echo -e "${RED}✗ FAIL${NC}: $1" + printf "${RED}✗ FAIL${NC}: %s\n" "$1" FAILED=$((FAILED + 1)) } log_info() { - echo -e "${YELLOW}→${NC} $1" + printf "${YELLOW}→${NC} %s\n" "$1" } log_header() { - echo -e "" - echo -e "${BLUE}============================================================${NC}" - echo -e "${BLUE}$1${NC}" - echo -e "${BLUE}============================================================${NC}" + printf "\n" + printf "${BLUE}============================================================${NC}\n" + printf "${BLUE}%s${NC}\n" "$1" + printf "${BLUE}============================================================${NC}\n" } -# Helper to run command in container run_in() { container=$1 shift docker exec "$container" "$@" } -# Cleanup test files -cleanup() { - log_info "Cleaning up test files..." - run_in $MOUNT1 sh -c "rm -rf $MOUNT_PATH/test-* $MOUNT_PATH/testdir 2>/dev/null || true" - run_in $MOUNT2 sh -c "rm -rf $MOUNT_PATH/test-* $MOUNT_PATH/testdir 2>/dev/null || true" +setup_test_folder() { + test_num=$1 + TEST_DIR="$MOUNT_PATH/test$test_num" + log_info "Setting up isolated test folder: $TEST_DIR" + run_in $MOUNT1 rm -rf "$TEST_DIR" 2>/dev/null || true + run_in $MOUNT2 rm -rf "$TEST_DIR" 2>/dev/null || true + sleep 1 + run_in $MOUNT1 mkdir -p "$TEST_DIR" + run_in $MOUNT1 sync + sleep 1 + run_in $MOUNT1 ls -la "$TEST_DIR" >/dev/null 2>&1 || true + run_in $MOUNT2 ls -la "$MOUNT_PATH" >/dev/null 2>&1 || true + sleep 1 +} + +cleanup_test_folder() { + test_num=$1 + test_dir="$MOUNT_PATH/test$test_num" + log_info "Cleaning up test folder: $test_dir" + run_in $MOUNT1 rm -rf "$test_dir" 2>/dev/null || true + run_in $MOUNT2 rm -rf "$test_dir" 2>/dev/null || true run_in $MOUNT1 sync run_in $MOUNT2 sync - sleep 3 - # Force cache refresh on both mounts - run_in $MOUNT1 ls -la $MOUNT_PATH/ >/dev/null 2>&1 - run_in $MOUNT2 ls -la $MOUNT_PATH/ >/dev/null 2>&1 - sleep 3 + sleep 1 } -# Get .geesefs_symlinks file content via S3 API (bypassing FUSE mount) get_symlinks_file_s3() { dir_prefix=$1 if [ -z "$dir_prefix" ]; then @@ -73,11 +81,9 @@ get_symlinks_file_s3() { else key="${dir_prefix}/${SYMLINKS_FILE}" fi - docker exec geesefs-minio mc cat myminio/testbucket/"$key" 2>/dev/null || echo "" } -# Sync and wait for all files to be written to S3 sync_and_wait() { run_in $MOUNT1 sync 2>/dev/null || true run_in $MOUNT2 sync 2>/dev/null || true @@ -88,10 +94,8 @@ log_header "GeeseFS Symlinks File Tests" echo "Mount 1: $MOUNT1" echo "Mount 2: $MOUNT2" echo "Mount path: $MOUNT_PATH" -echo "Symlinks file: $SYMLINKS_FILE" echo "" -# Verify mounts are healthy log_info "Verifying mounts are healthy..." if ! docker exec $MOUNT1 mountpoint -q $MOUNT_PATH; then echo "ERROR: $MOUNT1 mount not ready" @@ -103,36 +107,33 @@ if ! docker exec $MOUNT2 mountpoint -q $MOUNT_PATH; then fi echo "Both mounts are ready!" -# Setup mc alias in minio container for direct S3 access log_info "Setting up MinIO client..." docker exec geesefs-minio mc alias set myminio http://localhost:9000 minioadmin minioadmin >/dev/null 2>&1 || true -cleanup - # ============================================================================== -log_header "TEST 1: Create symlink in container 1, verify .geesefs_symlinks file" +log_header "TEST 1: Create symlink, verify .geesefs_symlinks file" # ============================================================================== +setup_test_folder 1 + log_info "Creating target file..." -run_in $MOUNT1 sh -c "echo 'Hello from target' > $MOUNT_PATH/test-target.txt" +run_in $MOUNT1 sh -c "echo 'Hello from target' > $TEST_DIR/target.txt" sync_and_wait log_info "Creating symlink in container 1..." -run_in $MOUNT1 ln -sf test-target.txt $MOUNT_PATH/test-link.txt +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/link.txt sync_and_wait -# Verify symlink works in container 1 log_info "Verifying symlink in container 1..." -CONTENT=$(run_in $MOUNT1 cat $MOUNT_PATH/test-link.txt 2>/dev/null || echo "FAILED") +CONTENT=$(run_in $MOUNT1 cat $TEST_DIR/link.txt 2>/dev/null || echo "FAILED") if [ "$CONTENT" = "Hello from target" ]; then log_pass "Symlink works in container 1" else log_fail "Symlink doesn't work in container 1 (got: $CONTENT)" fi -# Check .geesefs_symlinks file exists via S3 log_info "Checking .geesefs_symlinks file via S3 API..." -SYMLINKS_CONTENT=$(get_symlinks_file_s3 "") +SYMLINKS_CONTENT=$(get_symlinks_file_s3 "test1") if [ -n "$SYMLINKS_CONTENT" ]; then log_pass ".geesefs_symlinks file exists in S3" echo " Content:" @@ -141,230 +142,267 @@ else log_fail ".geesefs_symlinks file not found in S3" fi -# Verify .geesefs_symlinks file contains our symlink -if echo "$SYMLINKS_CONTENT" | grep -q "test-link.txt"; then - log_pass ".geesefs_symlinks file contains test-link.txt entry" +if echo "$SYMLINKS_CONTENT" | grep -q "link.txt"; then + log_pass ".geesefs_symlinks file contains link.txt entry" else - log_fail ".geesefs_symlinks file missing test-link.txt entry" + log_fail ".geesefs_symlinks file missing link.txt entry" fi -if echo "$SYMLINKS_CONTENT" | grep -q "test-target.txt"; then - log_pass ".geesefs_symlinks file contains correct target (test-target.txt)" +if echo "$SYMLINKS_CONTENT" | grep -q "target.txt"; then + log_pass ".geesefs_symlinks file contains correct target" else log_fail ".geesefs_symlinks file missing correct target" fi +cleanup_test_folder 1 + # ============================================================================== -log_header "TEST 2: Verify symlink is visible and works in container 2" +log_header "TEST 2: Cross-mount visibility (container 1 -> container 2)" # ============================================================================== -log_info "Waiting for cache refresh and checking container 2..." +setup_test_folder 2 + +log_info "Creating target file in container 1..." +run_in $MOUNT1 sh -c "echo 'Cross-mount content' > $TEST_DIR/target.txt" +sync_and_wait + +log_info "Creating symlink in container 1..." +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/link.txt +sync_and_wait + +log_info "Verifying symlink in container 1..." +CONTENT1=$(run_in $MOUNT1 cat $TEST_DIR/link.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT1" = "Cross-mount content" ]; then + log_pass "Symlink works in container 1" +else + log_fail "Symlink doesn't work in container 1 (got: $CONTENT1)" +fi + +log_info "Waiting for cache refresh..." sleep 5 -run_in $MOUNT2 ls -la $MOUNT_PATH/ >/dev/null 2>&1 || true +run_in $MOUNT2 ls -la $TEST_DIR/ >/dev/null 2>&1 || true sleep 2 -log_info "Checking if symlink is visible in container 2..." -SYMLINK_VISIBLE_M2="no" -if run_in $MOUNT2 test -L $MOUNT_PATH/test-link.txt; then - SYMLINK_VISIBLE_M2="yes" +log_info "Checking symlink visibility in container 2..." +SYMLINK_VISIBLE="no" +if run_in $MOUNT2 test -L $TEST_DIR/link.txt; then + SYMLINK_VISIBLE="yes" fi -CONTENT2=$(run_in $MOUNT2 cat $MOUNT_PATH/test-link.txt 2>/dev/null || echo "FAILED") -TARGET2=$(run_in $MOUNT2 readlink $MOUNT_PATH/test-link.txt 2>/dev/null || echo "FAILED") +CONTENT2=$(run_in $MOUNT2 cat $TEST_DIR/link.txt 2>/dev/null || echo "FAILED") +TARGET2=$(run_in $MOUNT2 readlink $TEST_DIR/link.txt 2>/dev/null || echo "FAILED") -log_info "Symlink visible in container 2: $SYMLINK_VISIBLE_M2" -log_info "Content via container 2: $CONTENT2" -log_info "Target via container 2: $TARGET2" +log_info "Symlink visible: $SYMLINK_VISIBLE, content: $CONTENT2, target: $TARGET2" -# Cross-mount visibility is required - symlinks should be visible via .geesefs_symlinks file -if [ "$SYMLINK_VISIBLE_M2" = "yes" ]; then +if [ "$SYMLINK_VISIBLE" = "yes" ]; then log_pass "Symlink is visible in container 2" else log_fail "Symlink not visible in container 2" fi -if [ "$CONTENT2" = "Hello from target" ]; then +if [ "$CONTENT2" = "Cross-mount content" ]; then log_pass "Symlink content correct in container 2" else log_fail "Symlink content incorrect in container 2 (got: $CONTENT2)" fi -if [ "$TARGET2" = "test-target.txt" ]; then +if [ "$TARGET2" = "target.txt" ]; then log_pass "Symlink target correct in container 2" else log_fail "Symlink target incorrect in container 2 (got: $TARGET2)" fi +cleanup_test_folder 2 + # ============================================================================== -log_header "TEST 3: Create symlink in subdirectory" +log_header "TEST 3: Symlink in nested subdirectory" # ============================================================================== -log_info "Creating subdirectory and files..." -run_in $MOUNT1 mkdir -p $MOUNT_PATH/testdir -run_in $MOUNT1 sh -c "echo 'Subdir target content' > $MOUNT_PATH/testdir/target.txt" +setup_test_folder 3 + +log_info "Creating nested subdirectory..." +run_in $MOUNT1 mkdir -p $TEST_DIR/subdir +run_in $MOUNT1 sh -c "echo 'Nested content' > $TEST_DIR/subdir/target.txt" sync_and_wait log_info "Creating symlink in subdirectory..." -run_in $MOUNT1 ln -sf target.txt $MOUNT_PATH/testdir/link.txt +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/subdir/link.txt sync_and_wait -# Check .geesefs_symlinks file in subdirectory -log_info "Checking .geesefs_symlinks file in subdirectory via S3..." -SUBDIR_SYMLINKS=$(get_symlinks_file_s3 "testdir") +log_info "Checking .geesefs_symlinks in subdirectory..." +SUBDIR_SYMLINKS=$(get_symlinks_file_s3 "test3/subdir") if [ -n "$SUBDIR_SYMLINKS" ]; then - log_pass ".geesefs_symlinks file exists in testdir/" + log_pass ".geesefs_symlinks file exists in subdir" echo " Content:" echo "$SUBDIR_SYMLINKS" | sed 's/^/ /' else - log_fail ".geesefs_symlinks file not found in testdir/" + log_fail ".geesefs_symlinks file not found in subdir" fi if echo "$SUBDIR_SYMLINKS" | grep -q "link.txt"; then - log_pass "Subdirectory .geesefs_symlinks file contains link.txt entry" + log_pass "Subdirectory symlinks file contains link.txt" else - log_fail "Subdirectory .geesefs_symlinks file missing link.txt entry" + log_fail "Subdirectory symlinks file missing link.txt" fi -# Verify symlink works in container 1 first log_info "Verifying subdirectory symlink in container 1..." -SUBDIR_CONTENT_M1=$(run_in $MOUNT1 cat $MOUNT_PATH/testdir/link.txt 2>/dev/null || echo "FAILED") -if [ "$SUBDIR_CONTENT_M1" = "Subdir target content" ]; then +CONTENT_M1=$(run_in $MOUNT1 cat $TEST_DIR/subdir/link.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT_M1" = "Nested content" ]; then log_pass "Subdirectory symlink works in container 1" else - log_fail "Subdirectory symlink doesn't work in container 1 (got: $SUBDIR_CONTENT_M1)" + log_fail "Subdirectory symlink doesn't work in container 1 (got: $CONTENT_M1)" fi -# Verify in container 2 (with cache delay) -log_info "Waiting for cache refresh in container 2..." +log_info "Waiting for cache refresh..." sleep 5 -run_in $MOUNT2 ls -la $MOUNT_PATH/testdir/ >/dev/null 2>&1 || true +run_in $MOUNT2 ls -la $TEST_DIR/subdir/ >/dev/null 2>&1 || true sleep 2 -SUBDIR_CONTENT_M2=$(run_in $MOUNT2 cat $MOUNT_PATH/testdir/link.txt 2>/dev/null || echo "FAILED") -if [ "$SUBDIR_CONTENT_M2" = "Subdir target content" ]; then +CONTENT_M2=$(run_in $MOUNT2 cat $TEST_DIR/subdir/link.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT_M2" = "Nested content" ]; then log_pass "Subdirectory symlink works in container 2" else - log_fail "Subdirectory symlink doesn't work in container 2 (got: $SUBDIR_CONTENT_M2)" + log_fail "Subdirectory symlink doesn't work in container 2 (got: $CONTENT_M2)" fi +cleanup_test_folder 3 + # ============================================================================== -log_header "TEST 4: Delete symlink and verify .geesefs_symlinks file is updated" +log_header "TEST 4: Delete symlink updates .geesefs_symlinks" # ============================================================================== -log_info "Deleting symlink in container 1..." -run_in $MOUNT1 rm -f $MOUNT_PATH/test-link.txt 2>/dev/null || true +setup_test_folder 4 + +log_info "Creating target and symlink..." +run_in $MOUNT1 sh -c "echo 'Delete test' > $TEST_DIR/target.txt" +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/link.txt +sync_and_wait + +log_info "Verifying symlink exists..." +if run_in $MOUNT1 test -L $TEST_DIR/link.txt; then + log_info "Symlink exists" +else + log_fail "Symlink creation failed" +fi + +log_info "Deleting symlink..." +run_in $MOUNT1 rm -f $TEST_DIR/link.txt 2>/dev/null || true sync_and_wait -# Check .geesefs_symlinks file is updated -log_info "Checking .geesefs_symlinks file after deletion..." -SYMLINKS_AFTER_DELETE=$(get_symlinks_file_s3 "") -if echo "$SYMLINKS_AFTER_DELETE" | grep -q "test-link.txt"; then - log_fail ".geesefs_symlinks file still contains deleted symlink" +log_info "Checking .geesefs_symlinks after deletion..." +SYMLINKS_AFTER=$(get_symlinks_file_s3 "test4") +if echo "$SYMLINKS_AFTER" | grep -q "link.txt"; then + log_fail ".geesefs_symlinks still contains deleted symlink" else - log_pass ".geesefs_symlinks file no longer contains deleted symlink" + log_pass ".geesefs_symlinks no longer contains deleted symlink" fi -# Verify symlink is gone in container 1 first -log_info "Verifying symlink is deleted in container 1..." -if run_in $MOUNT1 test -e $MOUNT_PATH/test-link.txt 2>/dev/null; then - log_fail "Symlink still exists in container 1 after deletion" +log_info "Verifying symlink deleted in container 1..." +if run_in $MOUNT1 test -e $TEST_DIR/link.txt 2>/dev/null; then + log_fail "Symlink still exists in container 1" else log_pass "Symlink properly deleted in container 1" fi -# Verify symlink is gone in container 2 (with cache delay) -log_info "Waiting for cache refresh in container 2..." +log_info "Waiting for cache refresh..." sleep 5 -run_in $MOUNT2 ls -la $MOUNT_PATH/ >/dev/null 2>&1 || true +run_in $MOUNT2 ls -la $TEST_DIR/ >/dev/null 2>&1 || true sleep 2 -if run_in $MOUNT2 test -e $MOUNT_PATH/test-link.txt 2>/dev/null; then - log_fail "Symlink still exists in container 2 after deletion" +if run_in $MOUNT2 test -e $TEST_DIR/link.txt 2>/dev/null; then + log_fail "Symlink still exists in container 2" else log_pass "Symlink properly deleted in container 2" fi +cleanup_test_folder 4 + # ============================================================================== -log_header "TEST 5: Create symlink from container 2, verify in container 1" +log_header "TEST 5: Cross-mount visibility (container 2 -> container 1)" # ============================================================================== -# Create target file from container 2 (independent of other tests) +setup_test_folder 5 + log_info "Creating target file from container 2..." -run_in $MOUNT2 sh -c "echo 'Content from container 2 target' > $MOUNT_PATH/test-target-from-2.txt" +run_in $MOUNT2 sh -c "echo 'From container 2' > $TEST_DIR/target.txt" sync_and_wait log_info "Creating symlink from container 2..." -run_in $MOUNT2 ln -sf test-target-from-2.txt $MOUNT_PATH/test-link-from-2.txt +run_in $MOUNT2 ln -sf target.txt $TEST_DIR/link.txt sync_and_wait -# Verify symlink works in container 2 first (the creator) -log_info "Verifying symlink in container 2 (creator)..." -if run_in $MOUNT2 test -L $MOUNT_PATH/test-link-from-2.txt; then +log_info "Verifying symlink in container 2..." +if run_in $MOUNT2 test -L $TEST_DIR/link.txt; then log_pass "Symlink created by container 2 exists" else log_fail "Symlink creation failed in container 2" fi -CONTENT_M2=$(run_in $MOUNT2 cat $MOUNT_PATH/test-link-from-2.txt 2>/dev/null || echo "FAILED") -if [ "$CONTENT_M2" = "Content from container 2 target" ]; then - log_pass "Symlink from container 2 works in container 2" +CONTENT_M2=$(run_in $MOUNT2 cat $TEST_DIR/link.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT_M2" = "From container 2" ]; then + log_pass "Symlink works in container 2" else - log_fail "Symlink from container 2 doesn't work in container 2 (got: $CONTENT_M2)" + log_fail "Symlink doesn't work in container 2 (got: $CONTENT_M2)" fi -# Verify in container 1 (with cache delay) log_info "Waiting for cache refresh in container 1..." sleep 5 -run_in $MOUNT1 ls -la $MOUNT_PATH/ >/dev/null 2>&1 || true +run_in $MOUNT1 ls -la $TEST_DIR/ >/dev/null 2>&1 || true sleep 2 -SYMLINK_VISIBLE_M1="no" -if run_in $MOUNT1 test -L $MOUNT_PATH/test-link-from-2.txt; then - SYMLINK_VISIBLE_M1="yes" +SYMLINK_VISIBLE="no" +if run_in $MOUNT1 test -L $TEST_DIR/link.txt; then + SYMLINK_VISIBLE="yes" fi -CONTENT_M1=$(run_in $MOUNT1 cat $MOUNT_PATH/test-link-from-2.txt 2>/dev/null || echo "FAILED") +CONTENT_M1=$(run_in $MOUNT1 cat $TEST_DIR/link.txt 2>/dev/null || echo "FAILED") -if [ "$SYMLINK_VISIBLE_M1" = "yes" ] && [ "$CONTENT_M1" = "Content from container 2 target" ]; then +if [ "$SYMLINK_VISIBLE" = "yes" ] && [ "$CONTENT_M1" = "From container 2" ]; then log_pass "Symlink from container 2 visible and works in container 1" else - log_fail "Symlink from container 2 not visible or incorrect in container 1 (visible: $SYMLINK_VISIBLE_M1, content: $CONTENT_M1)" + log_fail "Symlink from container 2 not visible/incorrect in container 1 (visible: $SYMLINK_VISIBLE, content: $CONTENT_M1)" fi +cleanup_test_folder 5 + # ============================================================================== -log_header "TEST 6: Verify .geesefs_symlinks file is hidden from directory listing" +log_header "TEST 6: .geesefs_symlinks file visibility" # ============================================================================== +setup_test_folder 6 + +log_info "Creating symlink..." +run_in $MOUNT1 sh -c "echo 'Visibility test' > $TEST_DIR/target.txt" +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/link.txt +sync_and_wait + log_info "Listing directory contents..." -DIR_LISTING=$(run_in $MOUNT1 ls -la $MOUNT_PATH/) +DIR_LISTING=$(run_in $MOUNT1 ls -la $TEST_DIR/) echo " Directory listing:" echo "$DIR_LISTING" | sed 's/^/ /' -# Note: .geesefs_symlinks visibility depends on GeeseFS implementation -# It may or may not be hidden from directory listings if echo "$DIR_LISTING" | grep -q "\.geesefs_symlinks"; then - log_info "Note: .geesefs_symlinks file is visible in directory listing" + log_info "Note: .geesefs_symlinks is visible in listing" log_pass ".geesefs_symlinks file exists (visibility is implementation-dependent)" else - log_pass ".geesefs_symlinks file is hidden from directory listing" + log_pass ".geesefs_symlinks file is hidden from listing" fi +cleanup_test_folder 6 + # ============================================================================== -# Summary +log_header "TEST SUMMARY" # ============================================================================== -cleanup - -log_header "TEST SUMMARY" echo "" -echo -e "Passed: ${GREEN}$PASSED${NC}" -echo -e "Failed: ${RED}$FAILED${NC}" +printf "Passed: ${GREEN}%s${NC}\n" "$PASSED" +printf "Failed: ${RED}%s${NC}\n" "$FAILED" echo "" if [ $FAILED -eq 0 ]; then - echo -e "${GREEN}All tests passed!${NC}" + printf "${GREEN}All tests passed!${NC}\n" exit 0 else - echo -e "${RED}Some tests failed!${NC}" + printf "${RED}Some tests failed!${NC}\n" exit 1 fi From f623a783fbe483facbc32bf71d812fa7afdfeaa0 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Mon, 2 Feb 2026 09:05:14 +0100 Subject: [PATCH 08/39] hide symlink files --- core/cfg/config.go | 1 + core/cfg/flags.go | 6 +++++ core/dir.go | 4 +-- docker/test_symlinks_file.sh | 47 +++++++++++++++++++++++++++++++----- 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/core/cfg/config.go b/core/cfg/config.go index c4216471..d858bae1 100644 --- a/core/cfg/config.go +++ b/core/cfg/config.go @@ -97,6 +97,7 @@ type FlagStorage struct { SymlinkAttr string SymlinksFile string // Name of the hidden file storing symlinks metadata EnableSymlinksFile bool // Use .symlinks file instead of object metadata for symlinks + HideSymlinksFile bool // Hide the .symlinks file from directory listings RefreshAttr string RefreshFilename string FlushFilename string diff --git a/core/cfg/flags.go b/core/cfg/flags.go index 457e4cf6..c26e9b60 100644 --- a/core/cfg/flags.go +++ b/core/cfg/flags.go @@ -571,6 +571,11 @@ MISC OPTIONS: Usage: "Name of the hidden file storing symlinks metadata when --enable-symlinks-file is used.", }, + cli.BoolTFlag{ + Name: "hide-symlinks-file", + Usage: "Hide the .symlinks file from directory listings. Set to false to show it. (default: true)", + }, + cli.StringFlag{ Name: "refresh-attr", Value: ".invalidate", @@ -874,6 +879,7 @@ func PopulateFlags(c *cli.Context) (ret *FlagStorage) { MtimeAttr: c.String("mtime-attr"), SymlinksFile: c.String("symlinks-file"), EnableSymlinksFile: c.Bool("enable-symlinks-file"), + HideSymlinksFile: c.Bool("hide-symlinks-file"), RefreshAttr: c.String("refresh-attr"), CachePath: c.String("cache"), MaxDiskCacheFD: int64(c.Int("max-disk-cache-fd")), diff --git a/core/dir.go b/core/dir.go index cff11565..45a9b1f5 100644 --- a/core/dir.go +++ b/core/dir.go @@ -451,8 +451,8 @@ func (dh *DirHandle) handleListResult(resp *ListBlobsOutput, prefix string, skip continue } - // Skip the .symlinks file from listing results - if fs.flags.EnableSymlinksFile && baseName == fs.flags.SymlinksFile { + // Skip the .symlinks file from listing results if hidden + if fs.flags.EnableSymlinksFile && fs.flags.HideSymlinksFile && baseName == fs.flags.SymlinksFile { continue } diff --git a/docker/test_symlinks_file.sh b/docker/test_symlinks_file.sh index 1e6ef937..0a0b7d6b 100755 --- a/docker/test_symlinks_file.sh +++ b/docker/test_symlinks_file.sh @@ -56,8 +56,13 @@ setup_test_folder() { run_in $MOUNT2 rm -rf "$TEST_DIR" 2>/dev/null || true sleep 1 run_in $MOUNT1 mkdir -p "$TEST_DIR" + if ! run_in $MOUNT1 test -d "$TEST_DIR"; then + log_fail "Failed to create test directory $TEST_DIR" + return 1 + fi run_in $MOUNT1 sync sleep 1 + # Verify directory is accessible run_in $MOUNT1 ls -la "$TEST_DIR" >/dev/null 2>&1 || true run_in $MOUNT2 ls -la "$MOUNT_PATH" >/dev/null 2>&1 || true sleep 1 @@ -120,10 +125,21 @@ log_info "Creating target file..." run_in $MOUNT1 sh -c "echo 'Hello from target' > $TEST_DIR/target.txt" sync_and_wait +# Debug: Verify target file was created +if ! run_in $MOUNT1 test -f $TEST_DIR/target.txt; then + log_fail "Target file was not created" +fi + log_info "Creating symlink in container 1..." run_in $MOUNT1 ln -sf target.txt $TEST_DIR/link.txt +SYMLINK_RC=$? +log_info "ln -sf exit code: $SYMLINK_RC" sync_and_wait +# Debug: Show directory contents after symlink creation +log_info "Debug: Directory contents after symlink creation:" +run_in $MOUNT1 ls -la $TEST_DIR/ 2>&1 | sed 's/^/ /' + log_info "Verifying symlink in container 1..." CONTENT=$(run_in $MOUNT1 cat $TEST_DIR/link.txt 2>/dev/null || echo "FAILED") if [ "$CONTENT" = "Hello from target" ]; then @@ -180,7 +196,19 @@ fi log_info "Waiting for cache refresh..." sleep 5 -run_in $MOUNT2 ls -la $TEST_DIR/ >/dev/null 2>&1 || true + +# Debug: Check .geesefs_symlinks file via S3 before checking container 2 +log_info "Debug: Checking .geesefs_symlinks via S3 API..." +SYMLINKS_S3=$(get_symlinks_file_s3 "test2") +if [ -n "$SYMLINKS_S3" ]; then + log_info "S3 symlinks file content:" + echo "$SYMLINKS_S3" | sed 's/^/ /' +else + log_info "Warning: .geesefs_symlinks not found in S3!" +fi + +log_info "Debug: Container 2 directory listing before access:" +run_in $MOUNT2 ls -la $TEST_DIR/ 2>&1 | sed 's/^/ /' sleep 2 log_info "Checking symlink visibility in container 2..." @@ -366,7 +394,7 @@ fi cleanup_test_folder 5 # ============================================================================== -log_header "TEST 6: .geesefs_symlinks file visibility" +log_header "TEST 6: .geesefs_symlinks file is hidden by default" # ============================================================================== setup_test_folder 6 @@ -376,16 +404,23 @@ run_in $MOUNT1 sh -c "echo 'Visibility test' > $TEST_DIR/target.txt" run_in $MOUNT1 ln -sf target.txt $TEST_DIR/link.txt sync_and_wait -log_info "Listing directory contents..." +log_info "Listing directory contents (expecting .geesefs_symlinks to be hidden)..." DIR_LISTING=$(run_in $MOUNT1 ls -la $TEST_DIR/) echo " Directory listing:" echo "$DIR_LISTING" | sed 's/^/ /' if echo "$DIR_LISTING" | grep -q "\.geesefs_symlinks"; then - log_info "Note: .geesefs_symlinks is visible in listing" - log_pass ".geesefs_symlinks file exists (visibility is implementation-dependent)" + log_fail ".geesefs_symlinks file is visible but should be hidden by default" else - log_pass ".geesefs_symlinks file is hidden from listing" + log_pass ".geesefs_symlinks file is hidden from listing (--hide-symlinks-file=true by default)" +fi + +log_info "Verifying .geesefs_symlinks exists via S3 API (bypassing FUSE)..." +SYMLINKS_CONTENT=$(get_symlinks_file_s3 "test6") +if [ -n "$SYMLINKS_CONTENT" ]; then + log_pass ".geesefs_symlinks file exists in S3 but is hidden from FUSE listing" +else + log_fail ".geesefs_symlinks file not found in S3" fi cleanup_test_folder 6 From eedded4c2afea0f63ebcfd737f153c467aae7736 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 3 Feb 2026 11:25:38 +0100 Subject: [PATCH 09/39] fixes --- core/backend_test.go | 5 + core/dir.go | 212 +++++++++++++++++++++++++++++++++--------- core/symlinks_test.go | 123 ++++++++++++++++++++++++ 3 files changed, 298 insertions(+), 42 deletions(-) diff --git a/core/backend_test.go b/core/backend_test.go index 3d97e502..ae736303 100644 --- a/core/backend_test.go +++ b/core/backend_test.go @@ -281,6 +281,11 @@ func (m *mockConditionalBackend) GetBlob(param *GetBlobInput) (*GetBlobOutput, e return nil, fmt.Errorf("PreconditionFailed: ETag mismatch for GetBlob") } + // Check IfNoneMatch condition - return 304 Not Modified if ETag matches + if param.IfNoneMatch != nil && *param.IfNoneMatch == obj.etag { + return nil, fmt.Errorf("304 Not Modified") + } + return &GetBlobOutput{ HeadBlobOutput: HeadBlobOutput{ BlobItemOutput: BlobItemOutput{ diff --git a/core/dir.go b/core/dir.go index 45a9b1f5..547d7021 100644 --- a/core/dir.go +++ b/core/dir.go @@ -508,26 +508,59 @@ func (dh *DirHandle) handleListResult(resp *ListBlobsOutput, prefix string, skip parent.refreshVirtualSymlinksLocked() } -// createVirtualSymlinksFromCache creates virtual symlink inodes from the symlinks cache +// createVirtualSymlinksFromCache creates or updates virtual symlink inodes from the symlinks cache // for symlinks that are not backed by S3 objects. // LOCKS_REQUIRED(parent.mu) -func (parent *Inode) createVirtualSymlinksFromCache() { +// Returns a list of FUSE notifications for inodes that need kernel cache invalidation. +func (parent *Inode) createVirtualSymlinksFromCache() []interface{} { fs := parent.fs if !fs.flags.EnableSymlinksFile || parent.dir == nil || parent.dir.symlinksCache == nil { - return + return nil } + s3Log.Debugf("createVirtualSymlinksFromCache: parent=%v symlinkCount=%d childCount=%d", + parent.FullName(), len(parent.dir.symlinksCache.Symlinks), len(parent.dir.Children)) + + var notifications []interface{} now := time.Now() for name, entry := range parent.dir.symlinksCache.Symlinks { - // Skip if inode already exists (from S3 listing or previous creation) - if parent.findChildUnlocked(name) != nil { - continue - } // Skip if deleted if _, deleted := parent.dir.DeletedChildren[name]; deleted { + s3Log.Debugf("createVirtualSymlinksFromCache: skipping deleted symlink %v", name) + continue + } + + // Check if inode already exists + existingInode := parent.findChildUnlocked(name) + if existingInode != nil { + // Update existing inode with symlink metadata if not already a symlink + // This handles the case where another mount created a symlink and we + // have a stale inode without symlink attributes + existingInode.mu.Lock() + if existingInode.userMetadata == nil { + existingInode.userMetadata = make(map[string][]byte) + } + oldTarget := string(existingInode.userMetadata[fs.flags.SymlinkAttr]) + wasSymlink := oldTarget != "" + // Always update the symlink target from the cache (another mount may have changed it) + existingInode.userMetadata[fs.flags.SymlinkAttr] = []byte(entry.Target) + existingInode.mu.Unlock() + s3Log.Debugf("createVirtualSymlinksFromCache: updated existing inode %v, oldTarget=%q newTarget=%q wasSymlink=%v", + name, oldTarget, entry.Target, wasSymlink) + // If this inode wasn't a symlink before but now is, we need to invalidate + // the kernel's cached attributes so it sees the new ModeSymlink bit + if !wasSymlink { + s3Log.Debugf("createVirtualSymlinksFromCache: sending NotifyInvalEntry for %v (became symlink)", name) + notifications = append(notifications, &fuseops.NotifyInvalEntry{ + Parent: parent.Id, + Name: name, + }) + } continue } + // Create virtual symlink inode + s3Log.Debugf("createVirtualSymlinksFromCache: creating new symlink inode %v -> %v", name, entry.Target) inode := NewInode(fs, parent, name) inode.userMetadata = make(map[string][]byte) inode.userMetadata[fs.flags.SymlinkAttr] = []byte(entry.Target) @@ -542,6 +575,7 @@ func (parent *Inode) createVirtualSymlinksFromCache() { fs.insertInode(parent, inode) inode.SetCacheState(ST_CACHED) } + return notifications } // refreshVirtualSymlinksLocked reloads the symlinks cache (conditional GET) and keeps @@ -597,11 +631,14 @@ func (parent *Inode) syncVirtualSymlinksFromCache() { i-- } + // Create/update virtual symlinks from cache and collect any notifications + // for inodes that became symlinks (need kernel attribute invalidation) + createNotifications := parent.createVirtualSymlinksFromCache() + notifications = append(notifications, createNotifications...) + if len(notifications) > 0 && fs.NotifyCallback != nil { fs.NotifyCallback(notifications) } - - parent.createVirtualSymlinksFromCache() } func maxName(resp *ListBlobsOutput, itemPos, prefixPos int) (string, int) { @@ -790,7 +827,11 @@ func (dh *DirHandle) loadListing() error { // Continue anyway, symlinks just won't work } // Always create virtual symlinks from cache (even if listing is cached) - parent.createVirtualSymlinksFromCache() + // and send notifications for any inodes that became symlinks + notifications := parent.createVirtualSymlinksFromCache() + if len(notifications) > 0 && parent.fs.NotifyCallback != nil { + parent.fs.NotifyCallback(notifications) + } } if !parent.dir.listDone && parent.dir.listMarker == "" { @@ -1008,22 +1049,31 @@ func (dh *DirHandle) ReadDir() (inode *Inode, err error) { dh.checkDirPosition() } - if dh.lastInternalOffset < 2 || dh.lastInternalOffset-2 >= len(dh.inode.dir.Children) { - // we've reached the end - parent.dir.listDone = false - if parent.dir.forgetDuringList { - parent.dir.DirTime = time.Time{} - parent.dir.Gaps = nil + fs := parent.fs + for { + if dh.lastInternalOffset < 2 || dh.lastInternalOffset-2 >= len(dh.inode.dir.Children) { + // we've reached the end + parent.dir.listDone = false + if parent.dir.forgetDuringList { + parent.dir.DirTime = time.Time{} + parent.dir.Gaps = nil + } + return } - return - } - child := dh.inode.dir.Children[dh.lastInternalOffset-2] - if dh.inode.dir.lastFromCloud != nil && child.Name == *dh.inode.dir.lastFromCloud { - dh.inode.dir.lastFromCloud = nil - } + child := dh.inode.dir.Children[dh.lastInternalOffset-2] + if dh.inode.dir.lastFromCloud != nil && child.Name == *dh.inode.dir.lastFromCloud { + dh.inode.dir.lastFromCloud = nil + } - return child, nil + // Skip the .symlinks file from listing if HideSymlinksFile is enabled + if fs.flags.EnableSymlinksFile && fs.flags.HideSymlinksFile && child.Name == fs.flags.SymlinksFile { + dh.lastInternalOffset++ + continue + } + + return child, nil + } } func (dh *DirHandle) CloseDir() error { @@ -1639,13 +1689,25 @@ func (parent *Inode) updateSymlinksFile(name string, target string, remove bool) data.AddSymlink(name, target) } - // Save with conditional write - newETag, err := SaveSymlinksFile(cloud, dirKey, symlinksFileName, data, etag) + // Define merge function for conflict resolution + mergeFn := func(currentData *SymlinksFileData) (*SymlinksFileData, error) { + // Re-apply our change to the current cloud state + if remove { + currentData.RemoveSymlink(name) + } else { + currentData.AddSymlink(name, target) + } + return currentData, nil + } + + // Save with conditional write and retry on conflict + const maxRetries = 5 + newETag, err := SaveSymlinksFileWithRetry(cloud, dirKey, symlinksFileName, data, etag, mergeFn, maxRetries) if err != nil { return err } - // Update cache + // Update cache - reload to get the merged state if there was a conflict parent.dir.symlinksCache = data parent.dir.symlinksCacheETag = newETag parent.dir.symlinksCacheTime = time.Now() @@ -2208,6 +2270,26 @@ func (parent *Inode) LookUpCached(name string) (inode *Inode, err error) { parent.mu.Lock() ok := false inode = parent.findChildUnlocked(name) + + // Debug logging for symlinks troubleshooting + if parent.fs.flags.EnableSymlinksFile { + childCount := len(parent.dir.Children) + cacheValid := !expired(parent.dir.DirTime, parent.fs.flags.StatCacheTTL) + hasSymlinksCache := parent.dir.symlinksCache != nil + var symlinkInCache bool + if hasSymlinksCache { + _, symlinkInCache = parent.dir.symlinksCache.Symlinks[name] + } + s3Log.Debugf("LookUpCached: parent=%v name=%v found=%v childCount=%d cacheValid=%v hasSymlinksCache=%v symlinkInCache=%v", + parent.FullName(), name, inode != nil, childCount, cacheValid, hasSymlinksCache, symlinkInCache) + if inode != nil { + inode.mu.Lock() + hasSymlinkAttr := inode.userMetadata != nil && inode.userMetadata[parent.fs.flags.SymlinkAttr] != nil + inode.mu.Unlock() + s3Log.Debugf("LookUpCached: inode=%v hasSymlinkAttr=%v", inode.Name, hasSymlinkAttr) + } + } + if inode != nil { ok = true if expired(inode.AttrTime, parent.fs.flags.StatCacheTTL) { @@ -2233,6 +2315,32 @@ func (parent *Inode) LookUpCached(name string) (inode *Inode, err error) { return nil, syscall.ENOENT } } + // For virtual symlinks, check symlinks cache before returning ENOENT + // Symlinks created on other mounts may not be in our Children list yet + if parent.fs.flags.EnableSymlinksFile { + if err := parent.loadSymlinksCache(); err != nil { + s3Log.Warnf("Failed to load symlinks cache for %v: %v", parent.FullName(), err) + } + if target, found := parent.getSymlinkTargetFromCache(name); found { + // Create virtual symlink inode from cache + inode = NewInode(parent.fs, parent, name) + inode.userMetadata = make(map[string][]byte) + inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) + now := time.Now() + inode.Attributes = InodeAttributes{ + Size: 0, + Mtime: now, + Ctime: now, + Uid: parent.fs.flags.Uid, + Gid: parent.fs.flags.Gid, + Mode: parent.fs.flags.FileMode, + } + parent.fs.insertInode(parent, inode) + inode.SetCacheState(ST_CACHED) + parent.mu.Unlock() + return inode, nil + } + } if !expired(parent.dir.DirTime, parent.fs.flags.StatCacheTTL) { // Don't recheck from the server if directory cache is actual parent.mu.Unlock() @@ -2278,28 +2386,39 @@ func (parent *Inode) LookUp(name string, doSlurp bool) (*Inode, error) { if loaded { parent.mu.Lock() inode := parent.findChildUnlocked(name) - // For virtual symlinks, check symlinks cache if inode not found + // For virtual symlinks, check symlinks cache if inode not found or needs metadata refresh // Virtual symlinks from other mounts may not be in our directory cache yet - if inode == nil && parent.fs.flags.EnableSymlinksFile { + if parent.fs.flags.EnableSymlinksFile { if err := parent.loadSymlinksCache(); err != nil { s3Log.Warnf("Failed to load symlinks cache for %v: %v", parent.FullName(), err) } if target, ok := parent.getSymlinkTargetFromCache(name); ok { - // Create virtual symlink inode - inode = NewInode(parent.fs, parent, name) - inode.userMetadata = make(map[string][]byte) - inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) - now := time.Now() - inode.Attributes = InodeAttributes{ - Size: 0, - Mtime: now, - Ctime: now, - Uid: parent.fs.flags.Uid, - Gid: parent.fs.flags.Gid, - Mode: parent.fs.flags.FileMode, + if inode == nil { + // Create virtual symlink inode + inode = NewInode(parent.fs, parent, name) + inode.userMetadata = make(map[string][]byte) + inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) + now := time.Now() + inode.Attributes = InodeAttributes{ + Size: 0, + Mtime: now, + Ctime: now, + Uid: parent.fs.flags.Uid, + Gid: parent.fs.flags.Gid, + Mode: parent.fs.flags.FileMode, + } + parent.fs.insertInode(parent, inode) + inode.SetCacheState(ST_CACHED) + } else { + // Ensure existing inode has symlink metadata + inode.mu.Lock() + if inode.userMetadata == nil { + inode.userMetadata = make(map[string][]byte) + } + inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) + inode.SetAttrTime(time.Now()) + inode.mu.Unlock() } - parent.fs.insertInode(parent, inode) - inode.SetCacheState(ST_CACHED) } } parent.mu.Unlock() @@ -2333,6 +2452,15 @@ func (parent *Inode) LookUp(name string, doSlurp bool) (*Inode, error) { } parent.fs.insertInode(parent, inode) inode.SetCacheState(ST_CACHED) + } else { + // Ensure existing inode has symlink metadata + inode.mu.Lock() + if inode.userMetadata == nil { + inode.userMetadata = make(map[string][]byte) + } + inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) + inode.SetAttrTime(time.Now()) + inode.mu.Unlock() } parent.mu.Unlock() return inode, nil diff --git a/core/symlinks_test.go b/core/symlinks_test.go index 3e7817e8..12594cfb 100644 --- a/core/symlinks_test.go +++ b/core/symlinks_test.go @@ -578,3 +578,126 @@ type errorBackend struct { func (m *errorBackend) PutBlob(param *PutBlobInput) (*PutBlobOutput, error) { return nil, m.err } + +// ============================================================================ +// Tests for LoadSymlinksFileConditional (conditional GET / 304 Not Modified) +// ============================================================================ + +func (s *SymlinksTest) TestLoadSymlinksFileConditionalNotModified(t *C) { + mock := newMockConditionalBackend() + + // Pre-create a file + existingETag := "\"test-etag-v1\"" + mock.objects["testdir/.geesefs_symlinks"] = &mockStoredObject{ + data: []byte(`{"version":1,"symlinks":{"link1":{"target":"../target1","mtime":1}}}`), + etag: existingETag, + } + + // First load - should return data + data1, etag1, err := LoadSymlinksFileConditional(mock, "testdir", ".geesefs_symlinks", "") + t.Assert(err, IsNil) + t.Assert(data1, NotNil) + t.Assert(etag1, Equals, existingETag) + t.Assert(data1.HasSymlink("link1"), Equals, true) + + // Second load with same ETag - should return nil (304 Not Modified) + data2, etag2, err := LoadSymlinksFileConditional(mock, "testdir", ".geesefs_symlinks", existingETag) + t.Assert(err, IsNil) + t.Assert(data2, IsNil) // nil indicates no change + t.Assert(etag2, Equals, existingETag) +} + +func (s *SymlinksTest) TestLoadSymlinksFileConditionalModified(t *C) { + mock := newMockConditionalBackend() + + // Pre-create a file + existingETag := "\"test-etag-v1\"" + mock.objects["testdir/.geesefs_symlinks"] = &mockStoredObject{ + data: []byte(`{"version":1,"symlinks":{"link1":{"target":"../target1","mtime":1}}}`), + etag: existingETag, + } + + // Load with outdated ETag - should return new data + data, etag, err := LoadSymlinksFileConditional(mock, "testdir", ".geesefs_symlinks", "\"old-etag\"") + t.Assert(err, IsNil) + t.Assert(data, NotNil) + t.Assert(etag, Equals, existingETag) + t.Assert(data.HasSymlink("link1"), Equals, true) +} + +func (s *SymlinksTest) TestLoadSymlinksFileConditionalFileDeleted(t *C) { + mock := newMockConditionalBackend() + + // File doesn't exist - should return empty data regardless of cachedETag + data, etag, err := LoadSymlinksFileConditional(mock, "testdir", ".geesefs_symlinks", "\"some-old-etag\"") + t.Assert(err, IsNil) + t.Assert(data, NotNil) // Returns empty data, not nil + t.Assert(data.IsEmpty(), Equals, true) + t.Assert(etag, Equals, "") +} + +// ============================================================================ +// Tests for symlinks cache lookup scenarios (simulates LookUpCached behavior) +// ============================================================================ + +func (s *SymlinksTest) TestSymlinksCacheLookupWhenInodeNotInChildren(t *C) { + // This test simulates the scenario where: + // - Mount 1 creates a symlink (updates .geesefs_symlinks) + // - Mount 2's directory cache is valid but doesn't have the inode + // - Mount 2 should find the symlink via the symlinks cache + + data := NewSymlinksFileData() + data.AddSymlink("new-symlink", "../target-file.txt") + + // Verify the symlink can be found in the cache + target, found := data.GetSymlink("new-symlink") + t.Assert(found, Equals, true) + t.Assert(target, Equals, "../target-file.txt") + + // Verify non-existent symlinks return false + _, found = data.GetSymlink("not-a-symlink") + t.Assert(found, Equals, false) +} + +func (s *SymlinksTest) TestSymlinksCacheUpdatesMergeCorrectly(t *C) { + // Simulates concurrent updates from different mounts + + // Mount 1 has these symlinks + mount1Data := NewSymlinksFileData() + mount1Data.AddSymlink("link-from-mount1", "../target1") + + // Mount 2 has these symlinks + mount2Data := NewSymlinksFileData() + mount2Data.AddSymlink("link-from-mount2", "../target2") + + // Simulate merge: Mount 2 loads Mount 1's data and adds its symlink + mergedData := NewSymlinksFileData() + mergedData.AddSymlink("link-from-mount1", "../target1") // From cloud + mergedData.AddSymlink("link-from-mount2", "../target2") // Local addition + + // Verify both symlinks are present + t.Assert(mergedData.HasSymlink("link-from-mount1"), Equals, true) + t.Assert(mergedData.HasSymlink("link-from-mount2"), Equals, true) + + target1, _ := mergedData.GetSymlink("link-from-mount1") + target2, _ := mergedData.GetSymlink("link-from-mount2") + t.Assert(target1, Equals, "../target1") + t.Assert(target2, Equals, "../target2") +} + +func (s *SymlinksTest) TestSymlinksCacheDeleteMergesCorrectly(t *C) { + // Simulates deleting a symlink when another mount added symlinks + + // Cloud has both symlinks + cloudData := NewSymlinksFileData() + cloudData.AddSymlink("link1", "../target1") + cloudData.AddSymlink("link2", "../target2") + + // Mount wants to delete link1 + cloudData.RemoveSymlink("link1") + + // Verify only link2 remains + t.Assert(cloudData.HasSymlink("link1"), Equals, false) + t.Assert(cloudData.HasSymlink("link2"), Equals, true) +} + From 7f7e865af3c39dbaac20fc7c0be723b198fa8321 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 3 Feb 2026 11:25:51 +0100 Subject: [PATCH 10/39] update doc --- doc/symlinks-file-design.md | 60 +++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/doc/symlinks-file-design.md b/doc/symlinks-file-design.md index ba73d047..75c4caa7 100644 --- a/doc/symlinks-file-design.md +++ b/doc/symlinks-file-design.md @@ -30,6 +30,10 @@ The `.geesefs_symlinks` file is the **authoritative source** for symlink informa - Conditional GET (If-None-Match) enables efficient cache validation - Atomic updates via conditional PUT (If-Match) +**Visibility:** + +The `.geesefs_symlinks` file is hidden from directory listings by default. Users only see the symlinks themselves, not the underlying metadata file. + ## Behavior Matrix *When `--enable-symlinks-file` is enabled:* @@ -81,17 +85,46 @@ If `.geesefs_symlinks` is deleted, the system will not reconstruct it automatica - Recreate the symlinks (which will repopulate `.geesefs_symlinks`), or - Manually author a valid `.geesefs_symlinks` JSON and upload it. +### Race Condition: Last Symlink Deletion + +When deleting the last symlink in a directory: + +- Mount A deletes the last symlink → deletes `.geesefs_symlinks` entirely +- Mount B simultaneously creates a new symlink → conditional PUT fails (no file to match ETag) + +**Resolution:** Mount B detects the missing file and creates a new `.geesefs_symlinks` with its symlink entry. + ## Implementation Details ### CreateSymlink (with EnableSymlinksFile=true) -1. Update `.geesefs_symlinks` file (conditional PUT) +1. Update `.geesefs_symlinks` file (conditional PUT with exponential backoff retry on conflict) 2. Insert inode with `ST_CACHED` state (no S3 object created—symlink is purely virtual) ### Unlink (symlink with EnableSymlinksFile=true) -1. Update `.geesefs_symlinks` file to remove entry -2. Remove inode from cache (no S3 deletion needed—symlink is purely virtual) +1. Update `.geesefs_symlinks` file to remove entry (conditional PUT with exponential backoff retry) +2. If this was the last symlink, delete `.geesefs_symlinks` file entirely +3. Remove inode from cache (no S3 deletion needed—symlink is purely virtual) + +### Concurrency Handling + +When multiple mounts attempt to update `.geesefs_symlinks` simultaneously: + +1. Each mount uses conditional PUT with `If-Match` (ETag) +2. If ETag mismatch (concurrent modification), the operation fails +3. On failure: fetch latest `.geesefs_symlinks`, merge changes, retry with exponential backoff +4. Maximum retry attempts with increasing delays to prevent thundering herd + +### lstat() Behavior + +When `lstat()` is called on a symlink: + +- **Size:** Length of the target path string +- **Mtime:** Value from `.geesefs_symlinks` metadata +- **Mode:** `S_IFLNK` (symlink) with appropriate permissions + +Note: `lstat()` does not follow the symlink. Use `stat()` to get target file attributes. ## Performance Considerations @@ -103,6 +136,27 @@ If `.geesefs_symlinks` is deleted, the system will not reconstruct it automatica **Trade-off:** Lower write and delete cost (no per-symlink S3 objects). If `.geesefs_symlinks` is deleted, symlinks are not visible until manual recovery is performed. +## Symlink Behavior + +### Dangling Symlinks + +Symlinks can point to non-existent targets (dangling symlinks). No validation is performed at creation time. + +### Cross-Directory Targets + +Symlinks can point to targets in other directories using relative paths: + +```text +../other-dir/file.txt +../../parent/sibling/file.txt +``` + +The target path is stored as-is (relative). Symlinks can also point to paths outside the mounted bucket—resolution is handled by the kernel at access time. + +### Symlink Chains (Not Supported) + +Chained symlinks (symlink → symlink → file) are **not supported**. A symlink must point directly to a regular file or directory, not to another symlink. + ## Configuration ```text From bcd330f93107b32a95e37c0ba0e615858e2db5a0 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 3 Feb 2026 11:26:04 +0100 Subject: [PATCH 11/39] split tests --- docker/README.md | 178 +++---- docker/justfile | 254 +++++----- docker/test_symlinks_file.sh | 24 +- .../conditional_writes/Dockerfile} | 0 docker/tests/conditional_writes/README.md | 34 ++ .../conditional_writes/docker-compose.yml | 66 +++ docker/tests/conditional_writes/justfile | 28 ++ .../test_conditional_writes.py | 18 +- .../geesefs_conditional}/Dockerfile.geesefs | 5 +- docker/tests/geesefs_conditional/README.md | 32 ++ .../geesefs_conditional/docker-compose.yml | 130 +++++ .../geesefs_conditional}/docker-entrypoint.sh | 17 + docker/tests/geesefs_conditional/justfile | 54 +++ .../test_geesefs_conditional.sh | 4 +- docker/tests/symlinks_file/Dockerfile.geesefs | 57 +++ docker/tests/symlinks_file/README.md | 53 ++ .../symlinks_file}/docker-compose.yml | 91 ++-- .../tests/symlinks_file/docker-entrypoint.sh | 52 ++ docker/tests/symlinks_file/justfile | 77 +++ .../tests/symlinks_file/test_symlinks_file.sh | 454 ++++++++++++++++++ 20 files changed, 1303 insertions(+), 325 deletions(-) mode change 100755 => 100644 docker/test_symlinks_file.sh rename docker/{Dockerfile.test-conditional => tests/conditional_writes/Dockerfile} (100%) create mode 100644 docker/tests/conditional_writes/README.md create mode 100644 docker/tests/conditional_writes/docker-compose.yml create mode 100644 docker/tests/conditional_writes/justfile rename docker/{ => tests/conditional_writes}/test_conditional_writes.py (96%) rename docker/{ => tests/geesefs_conditional}/Dockerfile.geesefs (91%) create mode 100644 docker/tests/geesefs_conditional/README.md create mode 100644 docker/tests/geesefs_conditional/docker-compose.yml rename docker/{ => tests/geesefs_conditional}/docker-entrypoint.sh (63%) create mode 100644 docker/tests/geesefs_conditional/justfile rename docker/{ => tests/geesefs_conditional}/test_geesefs_conditional.sh (99%) create mode 100644 docker/tests/symlinks_file/Dockerfile.geesefs create mode 100644 docker/tests/symlinks_file/README.md rename docker/{ => tests/symlinks_file}/docker-compose.yml (56%) create mode 100644 docker/tests/symlinks_file/docker-entrypoint.sh create mode 100644 docker/tests/symlinks_file/justfile create mode 100755 docker/tests/symlinks_file/test_symlinks_file.sh diff --git a/docker/README.md b/docker/README.md index 497f966e..2b0720a1 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,155 +1,101 @@ -# Docker Setup for GeeseFS +# GeeseFS Docker Tests -This folder contains Docker configurations for testing GeeseFS with MinIO. +This folder contains Docker-based integration tests for GeeseFS. ## Prerequisites - Docker and Docker Compose - [just](https://github.com/casey/just) command runner (recommended) -## Services +## Test Structure -- **minio**: MinIO S3-compatible storage server -- **minio-init**: Initializes the test bucket with proper permissions -- **geesefs-1**: First GeeseFS container mounting the S3 bucket (with `--enable-symlinks-file`) -- **geesefs-2**: Second GeeseFS container mounting the same bucket (with `--enable-symlinks-file`) -- **conditional-write-test**: Python test container for S3 conditional writes -- **symlinks-test**: Shell script test container for symlinks file feature -- **test-client**: MinIO client for verifying bucket contents +Each test suite has its own subfolder with isolated docker-compose setup: -## Quick Start with Just (Recommended) +```text +docker/ +├── tests/ +│ ├── conditional_writes/ # S3 If-Match/If-None-Match API tests +│ ├── geesefs_conditional/ # Conditional writes through FUSE mount +│ └── symlinks_file/ # .geesefs_symlinks cross-mount visibility +├── justfile # Main test runner +└── README.md +``` -The project includes a `justfile` with convenient commands. Run `just` to see all available recipes. +## Quick Start ```bash -# From the docker/ directory cd docker # Show all available commands just -# Start all services -just up - # Run all tests -just test +just test-all # Run specific tests -just test-conditional # S3 conditional write tests -just test-symlinks # Symlinks file tests -just test-geesefs-conditional # GeeseFS conditional tests via FUSE - -# View GeeseFS logs -just logs - -# Open MinIO Console in browser -just console - -# Test writing/reading across containers -just write-test -just read-test - -# Test symlinks -just create-symlink -just read-symlink +just test-conditional-writes # S3 API level conditional writes +just test-geesefs-conditional # FUSE level conditional writes +just test-symlinks # Symlinks file tests -# List bucket contents -just ls -just ls-recursive -just ls-symlinks - -# Shell into containers -just shell-1 # GeeseFS container 1 -just shell-2 # GeeseFS container 2 -just shell-minio +# Clean up all resources +just clean-all +``` -# Check service health -just health +## Individual Test Suites -# Stop all services -just down +### Conditional Writes Test (`tests/conditional_writes/`) -# Stop and remove volumes -just clean +Tests S3 conditional writes (If-Match/If-None-Match) directly against MinIO using the S3 API. -# Rebuild containers -just rebuild # Rebuild and restart GeeseFS containers -just rebuild-clean # Full rebuild with clean volumes -just full-rebuild # Clean, build, and start everything +```bash +cd tests/conditional_writes +just test # Run test +just clean # Cleanup ``` -## Quick Start with Docker Compose +### GeeseFS Conditional Test (`tests/geesefs_conditional/`) -If you prefer using Docker Compose directly: +Tests conditional writes through the GeeseFS FUSE mount with two concurrent mounts. ```bash -# From the docker/ directory -cd docker - -# Start all services -docker compose up -d - -# Run conditional write tests -docker compose run --rm conditional-write-test - -# Run symlinks file tests -docker compose run --rm symlinks-test - -# View GeeseFS logs -docker compose logs geesefs-1 geesefs-2 - -# Access MinIO Console -open http://localhost:9001 # user: minioadmin, password: minioadmin - -# Test writing from one container and reading from another -docker exec geesefs-mount-1 sh -c 'echo "Hello" > /mnt/s3/test.txt' -docker exec geesefs-mount-2 cat /mnt/s3/test.txt - -# Test symlinks -docker exec geesefs-mount-1 sh -c 'echo "target content" > /mnt/s3/target.txt' -docker exec geesefs-mount-1 ln -s target.txt /mnt/s3/link.txt -docker exec geesefs-mount-2 cat /mnt/s3/link.txt - -# Stop all services -docker compose down - -# Stop and remove volumes -docker compose down -v +cd tests/geesefs_conditional +just test # Run test +just up # Start services only +just shell-1 # Shell into mount 1 +just shell-2 # Shell into mount 2 +just logs # View GeeseFS logs +just clean # Cleanup ``` -## Configuration - -Environment variables for GeeseFS containers: - -| Variable | Default | Description | -|----------|---------|-------------| -| `AWS_ACCESS_KEY_ID` | minioadmin | S3 access key | -| `AWS_SECRET_ACCESS_KEY` | minioadmin | S3 secret key | -| `S3_ENDPOINT` | | S3 endpoint URL | -| `S3_BUCKET` | testbucket | Bucket to mount | -| `MOUNT_POINT` | /mnt/s3 | Mount location | -| `GEESEFS_OPTS` | --debug_s3 --enable-symlinks-file | Additional GeeseFS options | +### Symlinks File Test (`tests/symlinks_file/`) -## Symlinks File Tests +Tests the `.geesefs_symlinks` file feature for cross-mount symlink visibility. -The `symlinks-test` container tests the `--enable-symlinks-file` feature which stores symlink metadata in a hidden `.symlinks` JSON file per directory instead of object metadata. This is useful for S3 backends that don't return UserMetadata in listings. - -Tests include: +```bash +cd tests/symlinks_file +just test # Run test +just up # Start services only +just shell-1 # Shell into mount 1 +just shell-2 # Shell into mount 2 +just logs # View GeeseFS logs +just ls-symlinks # Show .geesefs_symlinks files +just console # Open MinIO web console +just clean # Cleanup +``` -1. **Create symlink** - Verify `.symlinks` file is created with correct JSON structure -2. **Cross-container visibility** - Symlinks created in container 1 are visible in container 2 -3. **Subdirectory symlinks** - Each directory has its own `.symlinks` file -4. **Delete symlink** - Verify `.symlinks` file is updated when symlink is removed -5. **Bidirectional sync** - Symlinks created in container 2 are visible in container 1 -6. **Hidden file** - `.symlinks` file is not visible in directory listings +## Developing Tests -## Conditional Write Tests +Each test folder contains: -The `conditional-write-test` container tests S3 conditional write features: +- `docker-compose.yml` - Container orchestration +- `Dockerfile.*` - Container build files +- `justfile` - Test-specific commands +- `test_*.sh` or `test_*.py` - Test script +- `README.md` - Test documentation -1. **If-None-Match: "*"** - Create only if object doesn't exist -2. **If-Match: \** - Update only if ETag matches (optimistic locking) -3. **Concurrent write race** - 5 writers, only 1 succeeds -4. **Read-modify-write pattern** - Counter increments with retry on conflict +To modify a test: -These features (added to S3 in August 2024) prevent race conditions when multiple writers access the same object. +1. `cd` into the test folder +2. Run `just up` to start services +3. Make changes and run `just test` +4. Run `just clean` when done diff --git a/docker/justfile b/docker/justfile index bbae3932..3411aeef 100644 --- a/docker/justfile +++ b/docker/justfile @@ -1,145 +1,133 @@ -# GeeseFS Docker Test Runner +# GeeseFS Docker Tests +# +# This folder contains Docker-based integration tests for GeeseFS. +# Each test suite has its own subfolder with isolated docker-compose setup. +# # Usage: just # Default recipe - show help default: @just --list -# Build all Docker images -build: - docker compose build - -# Start all services (MinIO + GeeseFS containers) -up: - docker compose up -d --build - -# Stop all services -down: - docker compose down - -# Stop all services and remove volumes -clean: - docker compose down -v - -# View logs from GeeseFS containers -logs: - docker compose logs -f geesefs-1 geesefs-2 - -# View logs from MinIO -logs-minio: - docker compose logs -f minio +# ============================================================================== +# Test Runners +# ============================================================================== # Run all tests -test: up +test-all: @echo "Running all tests..." - @just test-conditional + @just test-conditional-writes + @just test-geesefs-conditional @just test-symlinks + @echo "" @echo "All tests completed!" -# Run S3 conditional write tests (If-Match/If-None-Match) -test-conditional: up - @echo "Running conditional write tests..." - docker compose run --rm conditional-write-test - -# Run symlinks file tests -test-symlinks: up - @echo "Running symlinks file tests..." - docker compose run --rm symlinks-test - -# Run GeeseFS conditional tests via FUSE mount -test-geesefs-conditional: up - @echo "Running GeeseFS conditional tests..." - @chmod +x test_geesefs_conditional.sh - ./test_geesefs_conditional.sh - -# Check health of all services -health: - @echo "Checking service health..." - @docker compose ps - @echo "" - @echo "Mount status:" - @docker exec geesefs-mount-1 mountpoint -q /mnt/s3 && echo " geesefs-1: mounted" || echo " geesefs-1: NOT mounted" - @docker exec geesefs-mount-2 mountpoint -q /mnt/s3 && echo " geesefs-2: mounted" || echo " geesefs-2: NOT mounted" - -# Open MinIO console in browser -console: - open http://localhost:9001 - -# List bucket contents via MinIO client -ls: - docker exec geesefs-minio mc ls myminio/testbucket/ - -# List bucket contents recursively -ls-recursive: - docker exec geesefs-minio mc ls --recursive myminio/testbucket/ - -# Show .symlinks files in bucket -ls-symlinks: - @echo "Looking for .symlinks files..." - docker exec geesefs-minio mc find myminio/testbucket --name ".symlinks" --print || echo "No .symlinks files found" - -# Cat a .symlinks file (usage: just cat-symlinks [path]) -cat-symlinks path="": - @if [ -z "{{path}}" ]; then \ - docker exec geesefs-minio mc cat myminio/testbucket/.symlinks 2>/dev/null || echo "No .symlinks file in root"; \ - else \ - docker exec geesefs-minio mc cat myminio/testbucket/{{path}}/.symlinks 2>/dev/null || echo "No .symlinks file in {{path}}"; \ - fi - -# Create a test file from container 1 -write-test: - docker exec geesefs-mount-1 sh -c 'echo "Hello from container 1" > /mnt/s3/test-file.txt' - @echo "Created test-file.txt" - -# Read test file from container 2 -read-test: - docker exec geesefs-mount-2 cat /mnt/s3/test-file.txt - -# Create a symlink test -create-symlink: - docker exec geesefs-mount-1 sh -c 'echo "Target content" > /mnt/s3/target.txt' - docker exec geesefs-mount-1 ln -sf target.txt /mnt/s3/link.txt - @echo "Created symlink: link.txt -> target.txt" - -# Read symlink from container 2 -read-symlink: - @echo "Symlink target:" - docker exec geesefs-mount-2 readlink /mnt/s3/link.txt - @echo "Symlink content:" - docker exec geesefs-mount-2 cat /mnt/s3/link.txt - -# Shell into container 1 -shell-1: - docker exec -it geesefs-mount-1 sh - -# Shell into container 2 -shell-2: - docker exec -it geesefs-mount-2 sh - -# Shell into MinIO container -shell-minio: - docker exec -it geesefs-minio sh - -# Restart GeeseFS containers (useful after code changes) -restart: - docker compose restart geesefs-1 geesefs-2 - -# Rebuild and restart GeeseFS containers -rebuild: - docker compose build geesefs-1 geesefs-2 - docker compose up -d geesefs-1 geesefs-2 - -# Rebuild containers from scratch and clean volumes -rebuild-clean: clean - docker compose build --no-cache geesefs-1 geesefs-2 - docker compose up -d - @echo "Rebuild complete with clean volumes" - -# Clean test files from bucket -clean-test-files: - docker exec geesefs-mount-1 sh -c 'rm -rf /mnt/s3/test-* /mnt/s3/testdir 2>/dev/null || true' - @echo "Cleaned test files" - -# Full rebuild from scratch -full-rebuild: clean build up - @echo "Full rebuild complete" +# Run conditional writes test (S3 API level) +test-conditional-writes: + @echo "Running conditional writes test..." + cd tests/conditional_writes && just test + +# Run GeeseFS conditional test (FUSE level) +test-geesefs-conditional: + @echo "Running GeeseFS conditional test..." + cd tests/geesefs_conditional && just test + +# Run symlinks file test +test-symlinks: + @echo "Running symlinks file test..." + cd tests/symlinks_file && just test + +# ============================================================================== +# Cleanup +# ============================================================================== + +# Clean up all test resources +clean-all: + @echo "Cleaning up all tests..." + cd tests/conditional_writes && just clean || true + cd tests/geesefs_conditional && just clean || true + cd tests/symlinks_file && just clean || true + @echo "Cleanup complete!" + +# ============================================================================== +# Individual Test Management +# ============================================================================== + +# Start conditional writes test services +up-conditional-writes: + cd tests/conditional_writes && docker compose up -d --build + +# Start GeeseFS conditional test services +up-geesefs-conditional: + cd tests/geesefs_conditional && docker compose up -d --build + +# Start symlinks file test services +up-symlinks: + cd tests/symlinks_file && docker compose up -d --build + +# ============================================================================== +# Development Helpers +# ============================================================================== + +# View running containers +ps: + docker ps --filter "name=conditional-writes" --filter "name=geesefs-conditional" --filter "name=symlinks-test" + +# List all test folders +list-tests: + @echo "Available tests:" + @echo " - tests/conditional_writes : S3 If-Match/If-None-Match direct API tests" + @echo " - tests/geesefs_conditional : Conditional writes through FUSE mount" + @echo " - tests/symlinks_file : .geesefs_symlinks file cross-mount visibility" + +# ============================================================================== +# Symlinks File Test Commands +# ============================================================================== + +# View symlinks test logs +symlinks-logs: + cd tests/symlinks_file && docker compose logs -f geesefs-1 geesefs-2 + +# Shell into symlinks mount 1 +symlinks-shell-1: + docker exec -it symlinks-test-mount-1 sh + +# Shell into symlinks mount 2 +symlinks-shell-2: + docker exec -it symlinks-test-mount-2 sh + +# List symlinks in bucket +symlinks-ls: + docker exec symlinks-test-minio mc find myminio/testbucket --name ".geesefs_symlinks" --print || echo "None found" + +# Clean symlinks test +symlinks-clean: + cd tests/symlinks_file && just clean + +# ============================================================================== +# GeeseFS Conditional Test Commands +# ============================================================================== + +# View geesefs-conditional test logs +geesefs-logs: + cd tests/geesefs_conditional && docker compose logs -f geesefs-1 geesefs-2 + +# Shell into geesefs-conditional mount 1 +geesefs-shell-1: + docker exec -it geesefs-conditional-mount-1 sh + +# Shell into geesefs-conditional mount 2 +geesefs-shell-2: + docker exec -it geesefs-conditional-mount-2 sh + +# Clean geesefs-conditional test +geesefs-clean: + cd tests/geesefs_conditional && just clean + +# ============================================================================== +# Conditional Writes Test Commands +# ============================================================================== + +# Clean conditional-writes test +conditional-clean: + cd tests/conditional_writes && just clean diff --git a/docker/test_symlinks_file.sh b/docker/test_symlinks_file.sh old mode 100755 new mode 100644 index 0a0b7d6b..bb57fce9 --- a/docker/test_symlinks_file.sh +++ b/docker/test_symlinks_file.sh @@ -52,20 +52,30 @@ setup_test_folder() { test_num=$1 TEST_DIR="$MOUNT_PATH/test$test_num" log_info "Setting up isolated test folder: $TEST_DIR" + + # Clean up from MOUNT1 first and wait for sync run_in $MOUNT1 rm -rf "$TEST_DIR" 2>/dev/null || true + run_in $MOUNT1 sync + sleep 3 + + # Then clean up from MOUNT2 to handle any stale cache + # MOUNT2 needs time to see the change and avoid race conditions run_in $MOUNT2 rm -rf "$TEST_DIR" 2>/dev/null || true - sleep 1 + run_in $MOUNT2 sync + sleep 3 + + # Now create directory from MOUNT1 run_in $MOUNT1 mkdir -p "$TEST_DIR" + run_in $MOUNT1 sync + sleep 3 + + # Verify creation if ! run_in $MOUNT1 test -d "$TEST_DIR"; then log_fail "Failed to create test directory $TEST_DIR" + log_info "Debug: listing $MOUNT_PATH..." + run_in $MOUNT1 ls -la "$MOUNT_PATH" || true return 1 fi - run_in $MOUNT1 sync - sleep 1 - # Verify directory is accessible - run_in $MOUNT1 ls -la "$TEST_DIR" >/dev/null 2>&1 || true - run_in $MOUNT2 ls -la "$MOUNT_PATH" >/dev/null 2>&1 || true - sleep 1 } cleanup_test_folder() { diff --git a/docker/Dockerfile.test-conditional b/docker/tests/conditional_writes/Dockerfile similarity index 100% rename from docker/Dockerfile.test-conditional rename to docker/tests/conditional_writes/Dockerfile diff --git a/docker/tests/conditional_writes/README.md b/docker/tests/conditional_writes/README.md new file mode 100644 index 00000000..9e40c6b2 --- /dev/null +++ b/docker/tests/conditional_writes/README.md @@ -0,0 +1,34 @@ +# Conditional Writes Test + +This test validates S3 conditional write operations (If-Match / If-None-Match) directly against MinIO. + +## What it tests + +1. **If-None-Match: "*"** - Create only if object doesn't exist +2. **If-Match: ** - Update only if ETag matches (optimistic locking) +3. **Concurrent write conflict detection** + +These features were added to S3 in August 2024 and are useful for: + +- Preventing race conditions when multiple writers access the same object +- Implementing optimistic locking patterns +- Ensuring atomic create-if-not-exists operations + +## Running the test + +```bash +# Run the test +docker compose up --build + +# Or run interactively +docker compose run --rm test + +# Clean up +docker compose down -v +``` + +## Files + +- `docker-compose.yml` - Docker Compose configuration +- `Dockerfile` - Test container with Python and boto3 +- `test_conditional_writes.py` - Python test script diff --git a/docker/tests/conditional_writes/docker-compose.yml b/docker/tests/conditional_writes/docker-compose.yml new file mode 100644 index 00000000..c17800b7 --- /dev/null +++ b/docker/tests/conditional_writes/docker-compose.yml @@ -0,0 +1,66 @@ +# Conditional Writes Test - Tests S3 If-Match/If-None-Match via direct S3 API +# Usage: docker compose up --build + +services: + # MinIO S3-compatible storage + minio: + image: minio/minio:latest + container_name: conditional-writes-minio + command: server /data --console-address ":9001" + ports: + - "9100:9000" + - "9101:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + volumes: + - minio-data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - test-network + + # MinIO client to create bucket on startup + minio-init: + image: minio/mc:latest + container_name: conditional-writes-minio-init + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set myminio http://minio:9000 minioadmin minioadmin; + mc mb myminio/testbucket --ignore-existing; + mc anonymous set public myminio/testbucket; + echo 'Bucket testbucket created and configured'; + " + networks: + - test-network + + # Test container for conditional write tests using S3 API directly + test: + build: + context: . + dockerfile: Dockerfile + container_name: conditional-writes-test + depends_on: + minio-init: + condition: service_completed_successfully + environment: + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin + S3_ENDPOINT: http://minio:9000 + S3_BUCKET: testbucket + networks: + - test-network + command: ["python3", "/app/test_conditional_writes.py"] + +volumes: + minio-data: + +networks: + test-network: + driver: bridge diff --git a/docker/tests/conditional_writes/justfile b/docker/tests/conditional_writes/justfile new file mode 100644 index 00000000..db3cdf87 --- /dev/null +++ b/docker/tests/conditional_writes/justfile @@ -0,0 +1,28 @@ +# Conditional Writes Test Runner +# Usage: just + +# Default recipe - show help +default: + @just --list + +# Run the test +test: + docker compose up -d --build minio + docker compose run --rm minio-init + docker compose run --rm test + docker compose down + +# Run interactively +run: + docker compose run --rm test + +# Clean up +clean: + docker compose down -v --remove-orphans + +# View logs +logs: + docker compose logs -f + +# Rebuild and run +rebuild: clean test diff --git a/docker/test_conditional_writes.py b/docker/tests/conditional_writes/test_conditional_writes.py similarity index 96% rename from docker/test_conditional_writes.py rename to docker/tests/conditional_writes/test_conditional_writes.py index 1ea7ead4..a0be61ba 100644 --- a/docker/test_conditional_writes.py +++ b/docker/tests/conditional_writes/test_conditional_writes.py @@ -43,6 +43,17 @@ def get_s3_client(): ) +def ensure_bucket_exists(s3): + """Create the test bucket if it doesn't exist.""" + try: + s3.head_bucket(Bucket=S3_BUCKET) + print(f" Bucket '{S3_BUCKET}' already exists") + except ClientError: + print(f" Creating bucket '{S3_BUCKET}'...") + s3.create_bucket(Bucket=S3_BUCKET) + print(f" Bucket '{S3_BUCKET}' created") + + def cleanup_test_objects(s3, prefix="test-"): """Clean up test objects from previous runs.""" try: @@ -357,11 +368,11 @@ def main(): s3 = get_s3_client() - # Wait for MinIO to be ready + # Wait for MinIO to be ready and ensure bucket exists print("\nWaiting for S3 to be ready...") for i in range(30): try: - s3.head_bucket(Bucket=S3_BUCKET) + s3.list_buckets() # Check if S3 is available print("S3 is ready!") break except: @@ -370,6 +381,9 @@ def main(): print("ERROR: S3 not ready after 30 seconds") sys.exit(1) + # Ensure the test bucket exists + ensure_bucket_exists(s3) + # Clean up from previous runs print("\nCleaning up previous test objects...") cleanup_test_objects(s3) diff --git a/docker/Dockerfile.geesefs b/docker/tests/geesefs_conditional/Dockerfile.geesefs similarity index 91% rename from docker/Dockerfile.geesefs rename to docker/tests/geesefs_conditional/Dockerfile.geesefs index be93ed0c..f6b56a98 100644 --- a/docker/Dockerfile.geesefs +++ b/docker/tests/geesefs_conditional/Dockerfile.geesefs @@ -35,7 +35,8 @@ RUN apk add --no-cache \ fuse-common \ ca-certificates \ tzdata \ - bash + bash \ + netcat-openbsd # Copy the binary from builder COPY --from=builder /build/geesefs /geesefs @@ -50,7 +51,7 @@ RUN mkdir -p /var/log RUN echo "user_allow_other" >> /etc/fuse.conf # Copy entrypoint script -COPY docker/docker-entrypoint.sh /docker-entrypoint.sh +COPY docker/tests/geesefs_conditional/docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/docker/tests/geesefs_conditional/README.md b/docker/tests/geesefs_conditional/README.md new file mode 100644 index 00000000..8a9e2126 --- /dev/null +++ b/docker/tests/geesefs_conditional/README.md @@ -0,0 +1,32 @@ +# GeeseFS Conditional Writes Test + +This test validates that GeeseFS properly uses S3 conditional writes (If-Match/If-None-Match) when multiple mounts access the same files through the FUSE interface. + +## What it tests + +1. **If-None-Match behavior** - Create only if file doesn't exist +2. **If-Match behavior** - Update only if ETag matches +3. **Concurrent write conflict detection** between multiple mounts +4. **Cache invalidation** when files are modified by other mounts + +## Running the test + +```bash +# Run the test +docker compose up --build --abort-on-container-exit + +# Or use just +just test + +# Clean up +docker compose down -v +# Or +just clean +``` + +## Files + +- `docker-compose.yml` - Docker Compose configuration with MinIO + 2 GeeseFS mounts +- `Dockerfile.geesefs` - Builds GeeseFS binary with FUSE support +- `docker-entrypoint.sh` - Container entrypoint script +- `test_geesefs_conditional.sh` - Shell test script diff --git a/docker/tests/geesefs_conditional/docker-compose.yml b/docker/tests/geesefs_conditional/docker-compose.yml new file mode 100644 index 00000000..db1430be --- /dev/null +++ b/docker/tests/geesefs_conditional/docker-compose.yml @@ -0,0 +1,130 @@ +# GeeseFS Conditional Writes Test - Tests conditional writes through FUSE mount +# Usage: docker compose up --build + +services: + # MinIO S3-compatible storage + minio: + image: minio/minio:latest + container_name: geesefs-conditional-minio + command: server /data --console-address ":9001" + ports: + - "9200:9000" + - "9201:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + volumes: + - minio-data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - test-network + + # MinIO client to create bucket on startup + minio-init: + image: minio/mc:latest + container_name: geesefs-conditional-minio-init + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set myminio http://minio:9000 minioadmin minioadmin; + mc mb myminio/testbucket --ignore-existing; + mc anonymous set public myminio/testbucket; + echo 'Bucket testbucket created and configured'; + exit 0; + " + networks: + - test-network + + # GeeseFS container 1 + geesefs-1: + build: + context: ../../.. + dockerfile: docker/tests/geesefs_conditional/Dockerfile.geesefs + container_name: geesefs-conditional-mount-1 + depends_on: + minio-init: + condition: service_completed_successfully + privileged: true + cap_add: + - SYS_ADMIN + devices: + - /dev/fuse + security_opt: + - apparmor:unconfined + environment: + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin + S3_ENDPOINT: http://minio:9000 + S3_BUCKET: testbucket + MOUNT_POINT: /mnt/s3 + GEESEFS_OPTS: "--debug_s3 --debug_fuse --stat-cache-ttl 30s" + networks: + - test-network + healthcheck: + test: ["CMD", "mountpoint", "-q", "/mnt/s3"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + # GeeseFS container 2 + geesefs-2: + build: + context: ../../.. + dockerfile: docker/tests/geesefs_conditional/Dockerfile.geesefs + container_name: geesefs-conditional-mount-2 + depends_on: + minio-init: + condition: service_completed_successfully + privileged: true + cap_add: + - SYS_ADMIN + devices: + - /dev/fuse + security_opt: + - apparmor:unconfined + environment: + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin + S3_ENDPOINT: http://minio:9000 + S3_BUCKET: testbucket + MOUNT_POINT: /mnt/s3 + GEESEFS_OPTS: "--debug_s3 --debug_fuse --stat-cache-ttl 30s" + networks: + - test-network + healthcheck: + test: ["CMD", "mountpoint", "-q", "/mnt/s3"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + # Test runner container + test: + image: docker:cli + container_name: geesefs-conditional-test + depends_on: + geesefs-1: + condition: service_healthy + geesefs-2: + condition: service_healthy + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./test_geesefs_conditional.sh:/test.sh:ro + working_dir: / + command: ["sh", "/test.sh"] + networks: + - test-network + +volumes: + minio-data: + +networks: + test-network: + driver: bridge diff --git a/docker/docker-entrypoint.sh b/docker/tests/geesefs_conditional/docker-entrypoint.sh similarity index 63% rename from docker/docker-entrypoint.sh rename to docker/tests/geesefs_conditional/docker-entrypoint.sh index dd481d27..bb5cf34b 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/tests/geesefs_conditional/docker-entrypoint.sh @@ -17,6 +17,23 @@ echo "Bucket: $S3_BUCKET" echo "Mount Point: $MOUNT_POINT" echo "" +# Wait for MinIO to be reachable +echo "Waiting for MinIO at $S3_ENDPOINT..." +MINIO_HOST=$(echo "$S3_ENDPOINT" | sed -E 's|https?://([^:/]+).*|\1|') +MINIO_PORT=$(echo "$S3_ENDPOINT" | sed -E 's|.*:([0-9]+).*|\1|') +if [ "$MINIO_PORT" = "$S3_ENDPOINT" ]; then + MINIO_PORT=9000 +fi + +for i in $(seq 1 30); do + if nc -z "$MINIO_HOST" "$MINIO_PORT" 2>/dev/null; then + echo "MinIO is reachable!" + break + fi + echo " Attempt $i/30: waiting for $MINIO_HOST:$MINIO_PORT..." + sleep 1 +done + # If arguments are passed, run geesefs with those arguments if [ $# -gt 0 ]; then echo "Running: /geesefs $@" diff --git a/docker/tests/geesefs_conditional/justfile b/docker/tests/geesefs_conditional/justfile new file mode 100644 index 00000000..b1f97f78 --- /dev/null +++ b/docker/tests/geesefs_conditional/justfile @@ -0,0 +1,54 @@ +# GeeseFS Conditional Writes Test Runner +# Usage: just + +# Default recipe - show help +default: + @just --list + +# Build and run the test +test: + docker compose build + docker compose up -d minio + docker compose up minio-init + docker compose up -d geesefs-1 geesefs-2 + @echo "Waiting for GeeseFS mounts to become healthy..." + @sleep 15 + docker compose up test + docker compose down + +# Force rebuild images (no cache) +rebuild: + docker compose build --no-cache + +# Start services without running test +up: + docker compose up -d --build + +# Stop services +down: + docker compose down + +# Clean up (stop and remove volumes) +clean: + docker compose down -v --remove-orphans + +# View logs +logs: + docker compose logs -f geesefs-1 geesefs-2 + +# View all logs +logs-all: + docker compose logs -f + +# Shell into mount 1 +shell-1: + docker exec -it geesefs-conditional-mount-1 sh + +# Shell into mount 2 +shell-2: + docker exec -it geesefs-conditional-mount-2 sh + +# Force clean rebuild and run +fresh: clean + docker compose build --no-cache + just test diff --git a/docker/test_geesefs_conditional.sh b/docker/tests/geesefs_conditional/test_geesefs_conditional.sh similarity index 99% rename from docker/test_geesefs_conditional.sh rename to docker/tests/geesefs_conditional/test_geesefs_conditional.sh index 8c5b6314..a2dbdbe5 100755 --- a/docker/test_geesefs_conditional.sh +++ b/docker/tests/geesefs_conditional/test_geesefs_conditional.sh @@ -9,8 +9,8 @@ # Don't use set -e as it can cause issues with arithmetic expressions # set -e -MOUNT1="geesefs-mount-1" -MOUNT2="geesefs-mount-2" +MOUNT1="geesefs-conditional-mount-1" +MOUNT2="geesefs-conditional-mount-2" MOUNT_PATH="/mnt/s3" PASSED=0 FAILED=0 diff --git a/docker/tests/symlinks_file/Dockerfile.geesefs b/docker/tests/symlinks_file/Dockerfile.geesefs new file mode 100644 index 00000000..eff61030 --- /dev/null +++ b/docker/tests/symlinks_file/Dockerfile.geesefs @@ -0,0 +1,57 @@ +# Dockerfile for running GeeseFS with FUSE support and symlinks file enabled +FROM golang:1.25-alpine AS builder + +# Install required packages for building +RUN apk add --no-cache git make bash + +# Set working directory +WORKDIR /build + +# Copy go.mod and go.sum first for better caching +COPY go.mod go.sum ./ + +# Copy the s3ext module (local replace directive) +COPY s3ext/ ./s3ext/ + +# Download dependencies +RUN go mod download + +# Copy the rest of the source code +COPY . . + +# Set environment variables for static build +ENV CGO_ENABLED=0 +ENV GOOS=linux + +# Build binary +RUN go build -ldflags "-X main.Version=docker-build -s -w" -o geesefs . + +# Runtime stage with FUSE support +FROM alpine:latest + +# Install FUSE and other runtime dependencies +RUN apk add --no-cache \ + fuse \ + fuse-common \ + ca-certificates \ + tzdata \ + bash \ + netcat-openbsd + +# Copy the binary from builder +COPY --from=builder /build/geesefs /geesefs + +# Create mount point directory +RUN mkdir -p /mnt/s3 + +# Create log directory +RUN mkdir -p /var/log + +# Enable FUSE for non-root users +RUN echo "user_allow_other" >> /etc/fuse.conf + +# Copy entrypoint script +COPY docker/tests/symlinks_file/docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/docker/tests/symlinks_file/README.md b/docker/tests/symlinks_file/README.md new file mode 100644 index 00000000..cdd6dade --- /dev/null +++ b/docker/tests/symlinks_file/README.md @@ -0,0 +1,53 @@ +# GeeseFS Symlinks File Test + +This test validates the `.geesefs_symlinks` file feature for cross-mount symlink visibility. + +## What it tests + +1. **Symlink creation** - Creates symlinks and verifies `.geesefs_symlinks` file is written +2. **Cross-mount visibility** - Symlinks created on mount 1 should be visible on mount 2 +3. **Nested directories** - Symlinks work in subdirectories +4. **Symlink deletion** - Deleting a symlink updates the `.geesefs_symlinks` file +5. **Bidirectional visibility** - Symlinks created on mount 2 should be visible on mount 1 +6. **Hidden file** - `.geesefs_symlinks` file is hidden from directory listings + +## Running the test + +```bash +# Run the test +docker compose up --build --abort-on-container-exit + +# Or use just +just test + +# Clean up +docker compose down -v +# Or +just clean +``` + +## Files + +- `docker-compose.yml` - Docker Compose configuration with MinIO + 2 GeeseFS mounts +- `Dockerfile.geesefs` - Builds GeeseFS binary with FUSE support +- `docker-entrypoint.sh` - Container entrypoint script +- `test_symlinks_file.sh` - Shell test script + +## Debugging + +```bash +# Start services without running test +just up + +# Shell into mount 1 +just shell-1 + +# Shell into mount 2 +just shell-2 + +# View GeeseFS logs +just logs + +# Check symlinks files in bucket +just ls-symlinks +``` diff --git a/docker/docker-compose.yml b/docker/tests/symlinks_file/docker-compose.yml similarity index 56% rename from docker/docker-compose.yml rename to docker/tests/symlinks_file/docker-compose.yml index 0e4b9d79..338b7a4d 100644 --- a/docker/docker-compose.yml +++ b/docker/tests/symlinks_file/docker-compose.yml @@ -1,12 +1,15 @@ +# GeeseFS Symlinks File Test - Tests .geesefs_symlinks file cross-mount visibility +# Usage: docker compose up --build + services: # MinIO S3-compatible storage minio: image: minio/minio:latest - container_name: geesefs-minio + container_name: symlinks-test-minio command: server /data --console-address ":9001" ports: - - "9000:9000" # S3 API - - "9001:9001" # MinIO Console + - "9300:9000" + - "9301:9001" environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin @@ -18,12 +21,12 @@ services: timeout: 5s retries: 5 networks: - - geesefs-network + - test-network # MinIO client to create bucket on startup minio-init: image: minio/mc:latest - container_name: geesefs-minio-init + container_name: symlinks-test-minio-init depends_on: minio: condition: service_healthy @@ -36,14 +39,14 @@ services: exit 0; " networks: - - geesefs-network + - test-network - # GeeseFS container 1 - mounts the S3 bucket + # GeeseFS container 1 geesefs-1: build: - context: .. - dockerfile: docker/Dockerfile.geesefs - container_name: geesefs-mount-1 + context: ../../.. + dockerfile: docker/tests/symlinks_file/Dockerfile.geesefs + container_name: symlinks-test-mount-1 depends_on: minio-init: condition: service_completed_successfully @@ -60,9 +63,9 @@ services: S3_ENDPOINT: http://minio:9000 S3_BUCKET: testbucket MOUNT_POINT: /mnt/s3 - GEESEFS_OPTS: "--debug_s3 --debug_fuse --enable-symlinks-file --stat-cache-ttl 1s" + GEESEFS_OPTS: "--debug_s3 --debug_fuse --enable-symlinks-file --stat-cache-ttl 3s" networks: - - geesefs-network + - test-network healthcheck: test: ["CMD", "mountpoint", "-q", "/mnt/s3"] interval: 10s @@ -70,12 +73,12 @@ services: retries: 5 start_period: 10s - # GeeseFS container 2 - mounts the same S3 bucket + # GeeseFS container 2 geesefs-2: build: - context: .. - dockerfile: docker/Dockerfile.geesefs - container_name: geesefs-mount-2 + context: ../../.. + dockerfile: docker/tests/symlinks_file/Dockerfile.geesefs + container_name: symlinks-test-mount-2 depends_on: minio-init: condition: service_completed_successfully @@ -92,9 +95,9 @@ services: S3_ENDPOINT: http://minio:9000 S3_BUCKET: testbucket MOUNT_POINT: /mnt/s3 - GEESEFS_OPTS: "--debug_s3 --debug_fuse --enable-symlinks-file --stat-cache-ttl 1s" + GEESEFS_OPTS: "--debug_s3 --debug_fuse --enable-symlinks-file --stat-cache-ttl 3s" networks: - - geesefs-network + - test-network healthcheck: test: ["CMD", "mountpoint", "-q", "/mnt/s3"] interval: 10s @@ -102,48 +105,10 @@ services: retries: 5 start_period: 10s - # Test container for conditional write tests using S3 API directly - conditional-write-test: - build: - context: . - dockerfile: Dockerfile.test-conditional - container_name: geesefs-conditional-test - depends_on: - geesefs-1: - condition: service_healthy - geesefs-2: - condition: service_healthy - environment: - AWS_ACCESS_KEY_ID: minioadmin - AWS_SECRET_ACCESS_KEY: minioadmin - S3_ENDPOINT: http://minio:9000 - S3_BUCKET: testbucket - networks: - - geesefs-network - command: ["python3", "/app/test_conditional_writes.py"] - - # Test container that uses MinIO client to verify files - test-client: - image: minio/mc:latest - container_name: geesefs-test-client - depends_on: - geesefs-1: - condition: service_healthy - entrypoint: > - /bin/sh -c " - mc alias set myminio http://minio:9000 minioadmin minioadmin; - echo 'Listing bucket contents:'; - mc ls myminio/testbucket; - echo 'Test completed!'; - sleep infinity - " - networks: - - geesefs-network - - # Test container for symlinks file tests - symlinks-test: + # Test runner container + test: image: docker:cli - container_name: geesefs-symlinks-test + container_name: symlinks-test-runner depends_on: geesefs-1: condition: service_healthy @@ -151,15 +116,15 @@ services: condition: service_healthy volumes: - /var/run/docker.sock:/var/run/docker.sock - - ./test_symlinks_file.sh:/test_symlinks_file.sh:ro + - ./test_symlinks_file.sh:/test.sh:ro working_dir: / - command: ["sh", "/test_symlinks_file.sh"] + command: ["sh", "/test.sh"] networks: - - geesefs-network + - test-network volumes: minio-data: networks: - geesefs-network: + test-network: driver: bridge diff --git a/docker/tests/symlinks_file/docker-entrypoint.sh b/docker/tests/symlinks_file/docker-entrypoint.sh new file mode 100644 index 00000000..bb5cf34b --- /dev/null +++ b/docker/tests/symlinks_file/docker-entrypoint.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -e + +# Default values +S3_ENDPOINT="${S3_ENDPOINT:-http://minio:9000}" +S3_BUCKET="${S3_BUCKET:-testbucket}" +MOUNT_POINT="${MOUNT_POINT:-/mnt/s3}" +GEESEFS_OPTS="${GEESEFS_OPTS:-}" + +# Ensure mount point exists +mkdir -p "$MOUNT_POINT" + +echo "GeeseFS Docker Container" +echo "========================" +echo "S3 Endpoint: $S3_ENDPOINT" +echo "Bucket: $S3_BUCKET" +echo "Mount Point: $MOUNT_POINT" +echo "" + +# Wait for MinIO to be reachable +echo "Waiting for MinIO at $S3_ENDPOINT..." +MINIO_HOST=$(echo "$S3_ENDPOINT" | sed -E 's|https?://([^:/]+).*|\1|') +MINIO_PORT=$(echo "$S3_ENDPOINT" | sed -E 's|.*:([0-9]+).*|\1|') +if [ "$MINIO_PORT" = "$S3_ENDPOINT" ]; then + MINIO_PORT=9000 +fi + +for i in $(seq 1 30); do + if nc -z "$MINIO_HOST" "$MINIO_PORT" 2>/dev/null; then + echo "MinIO is reachable!" + break + fi + echo " Attempt $i/30: waiting for $MINIO_HOST:$MINIO_PORT..." + sleep 1 +done + +# If arguments are passed, run geesefs with those arguments +if [ $# -gt 0 ]; then + echo "Running: /geesefs $@" + exec /geesefs "$@" +fi + +# Default: mount the S3 bucket +echo "Mounting S3 bucket '$S3_BUCKET' to '$MOUNT_POINT'..." +echo "Running: /geesefs --endpoint $S3_ENDPOINT -o allow_other -f $GEESEFS_OPTS $S3_BUCKET $MOUNT_POINT" + +exec /geesefs \ + --endpoint "$S3_ENDPOINT" \ + -o allow_other \ + -f \ + $GEESEFS_OPTS \ + "$S3_BUCKET" "$MOUNT_POINT" diff --git a/docker/tests/symlinks_file/justfile b/docker/tests/symlinks_file/justfile new file mode 100644 index 00000000..38a02622 --- /dev/null +++ b/docker/tests/symlinks_file/justfile @@ -0,0 +1,77 @@ +# GeeseFS Symlinks File Test Runner +# Usage: just + +# Default recipe - show help +default: + @just --list + +# Build and run the test +test: + docker compose build + docker compose up -d minio + docker compose up minio-init + docker compose up -d geesefs-1 geesefs-2 + @echo "Waiting for GeeseFS mounts to become healthy..." + @sleep 15 + docker compose up test + docker compose down + +# Start services without running test +up: + docker compose up -d --build + +# Stop services +down: + docker compose down + +# Clean up (stop and remove volumes) +clean: + docker compose down -v --remove-orphans + +# View GeeseFS logs +logs: + docker compose logs -f geesefs-1 geesefs-2 + +# View all logs +logs-all: + docker compose logs -f + +# Shell into mount 1 +shell-1: + docker exec -it symlinks-test-mount-1 sh + +# Shell into mount 2 +shell-2: + docker exec -it symlinks-test-mount-2 sh + +# Shell into MinIO +shell-minio: + docker exec -it symlinks-test-minio sh + +# List bucket contents +ls: + docker exec symlinks-test-minio mc ls myminio/testbucket/ + +# List recursively +ls-recursive: + docker exec symlinks-test-minio mc ls --recursive myminio/testbucket/ + +# Show .geesefs_symlinks files +ls-symlinks: + @echo "Looking for .geesefs_symlinks files..." + docker exec symlinks-test-minio mc find myminio/testbucket --name ".geesefs_symlinks" --print || echo "None found" + +# Cat a symlinks file (usage: just cat-symlinks [path]) +cat-symlinks path="": + @if [ -z "{{path}}" ]; then \ + docker exec symlinks-test-minio mc cat myminio/testbucket/.geesefs_symlinks 2>/dev/null || echo "No file in root"; \ + else \ + docker exec symlinks-test-minio mc cat myminio/testbucket/{{path}}/.geesefs_symlinks 2>/dev/null || echo "No file in {{path}}"; \ + fi + +# Open MinIO console +console: + open http://localhost:9301 + +# Rebuild and run +rebuild: clean test diff --git a/docker/tests/symlinks_file/test_symlinks_file.sh b/docker/tests/symlinks_file/test_symlinks_file.sh new file mode 100755 index 00000000..48af1b02 --- /dev/null +++ b/docker/tests/symlinks_file/test_symlinks_file.sh @@ -0,0 +1,454 @@ +#!/bin/sh +# +# Test GeeseFS symlinks file feature through FUSE mount +# +# This script tests that GeeseFS properly manages symlinks using the +# .geesefs_symlinks file when --enable-symlinks-file is enabled. +# +# Each test runs in its own isolated subfolder to prevent interference. +# + +MOUNT1="symlinks-test-mount-1" +MOUNT2="symlinks-test-mount-2" +MINIO="symlinks-test-minio" +MOUNT_PATH="/mnt/s3" +SYMLINKS_FILE=".geesefs_symlinks" +PASSED=0 +FAILED=0 + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_pass() { + printf "${GREEN}✓ PASS${NC}: %s\n" "$1" + PASSED=$((PASSED + 1)) +} + +log_fail() { + printf "${RED}✗ FAIL${NC}: %s\n" "$1" + FAILED=$((FAILED + 1)) +} + +log_info() { + printf "${YELLOW}→${NC} %s\n" "$1" +} + +log_header() { + printf "\n" + printf "${BLUE}============================================================${NC}\n" + printf "${BLUE}%s${NC}\n" "$1" + printf "${BLUE}============================================================${NC}\n" +} + +run_in() { + container=$1 + shift + docker exec "$container" "$@" +} + +setup_test_folder() { + test_num=$1 + TEST_DIR="$MOUNT_PATH/test$test_num" + log_info "Setting up isolated test folder: $TEST_DIR" + + # Clean up from MOUNT1 first and wait for sync + run_in $MOUNT1 rm -rf "$TEST_DIR" 2>/dev/null || true + run_in $MOUNT1 sync + sleep 3 + + # Then clean up from MOUNT2 to handle any stale cache + # MOUNT2 needs time to see the change and avoid race conditions + run_in $MOUNT2 rm -rf "$TEST_DIR" 2>/dev/null || true + run_in $MOUNT2 sync + sleep 3 + + # Now create directory from MOUNT1 + run_in $MOUNT1 mkdir -p "$TEST_DIR" + run_in $MOUNT1 sync + sleep 3 + + # Verify creation + if ! run_in $MOUNT1 test -d "$TEST_DIR"; then + log_fail "Failed to create test directory $TEST_DIR" + log_info "Debug: listing $MOUNT_PATH..." + run_in $MOUNT1 ls -la "$MOUNT_PATH" || true + return 1 + fi +} + +cleanup_test_folder() { + test_num=$1 + test_dir="$MOUNT_PATH/test$test_num" + log_info "Cleaning up test folder: $test_dir" + run_in $MOUNT1 rm -rf "$test_dir" 2>/dev/null || true + run_in $MOUNT2 rm -rf "$test_dir" 2>/dev/null || true + run_in $MOUNT1 sync + run_in $MOUNT2 sync + sleep 1 +} + +get_symlinks_file_s3() { + dir_prefix=$1 + if [ -z "$dir_prefix" ]; then + key="$SYMLINKS_FILE" + else + key="${dir_prefix}/${SYMLINKS_FILE}" + fi + docker exec $MINIO mc cat myminio/testbucket/"$key" 2>/dev/null || echo "" +} + +sync_and_wait() { + run_in $MOUNT1 sync 2>/dev/null || true + run_in $MOUNT2 sync 2>/dev/null || true + sleep 2 +} + +log_header "GeeseFS Symlinks File Tests" +echo "Mount 1: $MOUNT1" +echo "Mount 2: $MOUNT2" +echo "Mount path: $MOUNT_PATH" +echo "" + +log_info "Verifying mounts are healthy..." +if ! docker exec $MOUNT1 mountpoint -q $MOUNT_PATH; then + echo "ERROR: $MOUNT1 mount not ready" + exit 1 +fi +if ! docker exec $MOUNT2 mountpoint -q $MOUNT_PATH; then + echo "ERROR: $MOUNT2 mount not ready" + exit 1 +fi +echo "Both mounts are ready!" + +log_info "Setting up MinIO client..." +docker exec $MINIO mc alias set myminio http://localhost:9000 minioadmin minioadmin >/dev/null 2>&1 || true + +# ============================================================================== +log_header "TEST 1: Create symlink, verify .geesefs_symlinks file" +# ============================================================================== + +setup_test_folder 1 + +log_info "Creating target file..." +run_in $MOUNT1 sh -c "echo 'Hello from target' > $TEST_DIR/target.txt" +sync_and_wait + +# Debug: Verify target file was created +if ! run_in $MOUNT1 test -f $TEST_DIR/target.txt; then + log_fail "Target file was not created" +fi + +log_info "Creating symlink in container 1..." +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/link.txt +SYMLINK_RC=$? +log_info "ln -sf exit code: $SYMLINK_RC" +sync_and_wait + +# Debug: Show directory contents after symlink creation +log_info "Debug: Directory contents after symlink creation:" +run_in $MOUNT1 ls -la $TEST_DIR/ 2>&1 | sed 's/^/ /' + +log_info "Verifying symlink in container 1..." +CONTENT=$(run_in $MOUNT1 cat $TEST_DIR/link.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT" = "Hello from target" ]; then + log_pass "Symlink works in container 1" +else + log_fail "Symlink doesn't work in container 1 (got: $CONTENT)" +fi + +log_info "Checking .geesefs_symlinks file via S3 API..." +SYMLINKS_CONTENT=$(get_symlinks_file_s3 "test1") +if [ -n "$SYMLINKS_CONTENT" ]; then + log_pass ".geesefs_symlinks file exists in S3" + echo " Content:" + echo "$SYMLINKS_CONTENT" | sed 's/^/ /' +else + log_fail ".geesefs_symlinks file not found in S3" +fi + +if echo "$SYMLINKS_CONTENT" | grep -q "link.txt"; then + log_pass ".geesefs_symlinks file contains link.txt entry" +else + log_fail ".geesefs_symlinks file missing link.txt entry" +fi + +if echo "$SYMLINKS_CONTENT" | grep -q "target.txt"; then + log_pass ".geesefs_symlinks file contains correct target" +else + log_fail ".geesefs_symlinks file missing correct target" +fi + +cleanup_test_folder 1 + +# ============================================================================== +log_header "TEST 2: Cross-mount visibility (container 1 -> container 2)" +# ============================================================================== + +setup_test_folder 2 + +log_info "Creating target file in container 1..." +run_in $MOUNT1 sh -c "echo 'Cross-mount content' > $TEST_DIR/target.txt" +sync_and_wait + +log_info "Creating symlink in container 1..." +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/link.txt +sync_and_wait + +log_info "Verifying symlink in container 1..." +CONTENT1=$(run_in $MOUNT1 cat $TEST_DIR/link.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT1" = "Cross-mount content" ]; then + log_pass "Symlink works in container 1" +else + log_fail "Symlink doesn't work in container 1 (got: $CONTENT1)" +fi + +log_info "Waiting for cache refresh..." +sleep 5 + +# Debug: Check .geesefs_symlinks file via S3 before checking container 2 +log_info "Debug: Checking .geesefs_symlinks via S3 API..." +SYMLINKS_S3=$(get_symlinks_file_s3 "test2") +if [ -n "$SYMLINKS_S3" ]; then + log_info "S3 symlinks file content:" + echo "$SYMLINKS_S3" | sed 's/^/ /' +else + log_info "Warning: .geesefs_symlinks not found in S3!" +fi + +log_info "Debug: Container 2 directory listing before access:" +run_in $MOUNT2 ls -la $TEST_DIR/ 2>&1 | sed 's/^/ /' +sleep 2 + +log_info "Checking symlink visibility in container 2..." +SYMLINK_VISIBLE="no" +if run_in $MOUNT2 test -L $TEST_DIR/link.txt; then + SYMLINK_VISIBLE="yes" +fi + +CONTENT2=$(run_in $MOUNT2 cat $TEST_DIR/link.txt 2>/dev/null || echo "FAILED") +TARGET2=$(run_in $MOUNT2 readlink $TEST_DIR/link.txt 2>/dev/null || echo "FAILED") + +log_info "Symlink visible: $SYMLINK_VISIBLE, content: $CONTENT2, target: $TARGET2" + +if [ "$SYMLINK_VISIBLE" = "yes" ]; then + log_pass "Symlink is visible in container 2" +else + log_fail "Symlink not visible in container 2" +fi + +if [ "$CONTENT2" = "Cross-mount content" ]; then + log_pass "Symlink content correct in container 2" +else + log_fail "Symlink content incorrect in container 2 (got: $CONTENT2)" +fi + +if [ "$TARGET2" = "target.txt" ]; then + log_pass "Symlink target correct in container 2" +else + log_fail "Symlink target incorrect in container 2 (got: $TARGET2)" +fi + +cleanup_test_folder 2 + +# ============================================================================== +log_header "TEST 3: Symlink in nested subdirectory" +# ============================================================================== + +setup_test_folder 3 + +log_info "Creating nested subdirectory..." +run_in $MOUNT1 mkdir -p $TEST_DIR/subdir +run_in $MOUNT1 sh -c "echo 'Nested content' > $TEST_DIR/subdir/target.txt" +sync_and_wait + +log_info "Creating symlink in subdirectory..." +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/subdir/link.txt +sync_and_wait + +log_info "Checking .geesefs_symlinks in subdirectory..." +SUBDIR_SYMLINKS=$(get_symlinks_file_s3 "test3/subdir") +if [ -n "$SUBDIR_SYMLINKS" ]; then + log_pass ".geesefs_symlinks file exists in subdir" + echo " Content:" + echo "$SUBDIR_SYMLINKS" | sed 's/^/ /' +else + log_fail ".geesefs_symlinks file not found in subdir" +fi + +if echo "$SUBDIR_SYMLINKS" | grep -q "link.txt"; then + log_pass "Subdirectory symlinks file contains link.txt" +else + log_fail "Subdirectory symlinks file missing link.txt" +fi + +log_info "Verifying subdirectory symlink in container 1..." +CONTENT_M1=$(run_in $MOUNT1 cat $TEST_DIR/subdir/link.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT_M1" = "Nested content" ]; then + log_pass "Subdirectory symlink works in container 1" +else + log_fail "Subdirectory symlink doesn't work in container 1 (got: $CONTENT_M1)" +fi + +log_info "Waiting for cache refresh..." +sleep 5 +run_in $MOUNT2 ls -la $TEST_DIR/subdir/ >/dev/null 2>&1 || true +sleep 2 + +CONTENT_M2=$(run_in $MOUNT2 cat $TEST_DIR/subdir/link.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT_M2" = "Nested content" ]; then + log_pass "Subdirectory symlink works in container 2" +else + log_fail "Subdirectory symlink doesn't work in container 2 (got: $CONTENT_M2)" +fi + +cleanup_test_folder 3 + +# ============================================================================== +log_header "TEST 4: Delete symlink updates .geesefs_symlinks" +# ============================================================================== + +setup_test_folder 4 + +log_info "Creating target and symlink..." +run_in $MOUNT1 sh -c "echo 'Delete test' > $TEST_DIR/target.txt" +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/link.txt +sync_and_wait + +log_info "Verifying symlink exists..." +if run_in $MOUNT1 test -L $TEST_DIR/link.txt; then + log_info "Symlink exists" +else + log_fail "Symlink creation failed" +fi + +log_info "Deleting symlink..." +run_in $MOUNT1 rm -f $TEST_DIR/link.txt 2>/dev/null || true +sync_and_wait + +log_info "Checking .geesefs_symlinks after deletion..." +SYMLINKS_AFTER=$(get_symlinks_file_s3 "test4") +if echo "$SYMLINKS_AFTER" | grep -q "link.txt"; then + log_fail ".geesefs_symlinks still contains deleted symlink" +else + log_pass ".geesefs_symlinks no longer contains deleted symlink" +fi + +log_info "Verifying symlink deleted in container 1..." +if run_in $MOUNT1 test -e $TEST_DIR/link.txt 2>/dev/null; then + log_fail "Symlink still exists in container 1" +else + log_pass "Symlink properly deleted in container 1" +fi + +log_info "Waiting for cache refresh..." +sleep 5 +run_in $MOUNT2 ls -la $TEST_DIR/ >/dev/null 2>&1 || true +sleep 2 + +if run_in $MOUNT2 test -e $TEST_DIR/link.txt 2>/dev/null; then + log_fail "Symlink still exists in container 2" +else + log_pass "Symlink properly deleted in container 2" +fi + +cleanup_test_folder 4 + +# ============================================================================== +log_header "TEST 5: Cross-mount visibility (container 2 -> container 1)" +# ============================================================================== + +setup_test_folder 5 + +log_info "Creating target file from container 2..." +run_in $MOUNT2 sh -c "echo 'From container 2' > $TEST_DIR/target.txt" +sync_and_wait + +log_info "Creating symlink from container 2..." +run_in $MOUNT2 ln -sf target.txt $TEST_DIR/link.txt +sync_and_wait + +log_info "Verifying symlink in container 2..." +if run_in $MOUNT2 test -L $TEST_DIR/link.txt; then + log_pass "Symlink created by container 2 exists" +else + log_fail "Symlink creation failed in container 2" +fi + +CONTENT_M2=$(run_in $MOUNT2 cat $TEST_DIR/link.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT_M2" = "From container 2" ]; then + log_pass "Symlink works in container 2" +else + log_fail "Symlink doesn't work in container 2 (got: $CONTENT_M2)" +fi + +log_info "Waiting for cache refresh in container 1..." +sleep 5 +run_in $MOUNT1 ls -la $TEST_DIR/ >/dev/null 2>&1 || true +sleep 2 + +SYMLINK_VISIBLE="no" +if run_in $MOUNT1 test -L $TEST_DIR/link.txt; then + SYMLINK_VISIBLE="yes" +fi + +CONTENT_M1=$(run_in $MOUNT1 cat $TEST_DIR/link.txt 2>/dev/null || echo "FAILED") + +if [ "$SYMLINK_VISIBLE" = "yes" ] && [ "$CONTENT_M1" = "From container 2" ]; then + log_pass "Symlink from container 2 visible and works in container 1" +else + log_fail "Symlink from container 2 not visible/incorrect in container 1 (visible: $SYMLINK_VISIBLE, content: $CONTENT_M1)" +fi + +cleanup_test_folder 5 + +# ============================================================================== +log_header "TEST 6: .geesefs_symlinks file is hidden by default" +# ============================================================================== + +setup_test_folder 6 + +log_info "Creating symlink..." +run_in $MOUNT1 sh -c "echo 'Visibility test' > $TEST_DIR/target.txt" +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/link.txt +sync_and_wait + +log_info "Listing directory contents (expecting .geesefs_symlinks to be hidden)..." +DIR_LISTING=$(run_in $MOUNT1 ls -la $TEST_DIR/) +echo " Directory listing:" +echo "$DIR_LISTING" | sed 's/^/ /' + +if echo "$DIR_LISTING" | grep -q "\.geesefs_symlinks"; then + log_fail ".geesefs_symlinks file is visible but should be hidden by default" +else + log_pass ".geesefs_symlinks file is hidden from listing (--hide-symlinks-file=true by default)" +fi + +log_info "Verifying .geesefs_symlinks exists via S3 API (bypassing FUSE)..." +SYMLINKS_CONTENT=$(get_symlinks_file_s3 "test6") +if [ -n "$SYMLINKS_CONTENT" ]; then + log_pass ".geesefs_symlinks file exists in S3 but is hidden from FUSE listing" +else + log_fail ".geesefs_symlinks file not found in S3" +fi + +cleanup_test_folder 6 + +# ============================================================================== +log_header "TEST SUMMARY" +# ============================================================================== + +echo "" +printf "Passed: ${GREEN}%s${NC}\n" "$PASSED" +printf "Failed: ${RED}%s${NC}\n" "$FAILED" +echo "" + +if [ $FAILED -eq 0 ]; then + printf "${GREEN}All tests passed!${NC}\n" + exit 0 +else + printf "${RED}Some tests failed!${NC}\n" + exit 1 +fi From ed5685012e3a2c5379ade739dcbbf0ad9d9d757c Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Mon, 16 Feb 2026 12:07:38 +0100 Subject: [PATCH 12/39] add test for direct symlink access --- .../tests/symlinks_file/test_symlinks_file.sh | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/docker/tests/symlinks_file/test_symlinks_file.sh b/docker/tests/symlinks_file/test_symlinks_file.sh index 48af1b02..6009246a 100755 --- a/docker/tests/symlinks_file/test_symlinks_file.sh +++ b/docker/tests/symlinks_file/test_symlinks_file.sh @@ -436,6 +436,56 @@ fi cleanup_test_folder 6 +# ============================================================================== +log_header "TEST 7: Direct access to symlink in subfolder without listing" +# ============================================================================== + +setup_test_folder 7 + +log_info "Creating nested subdirectory and target file from container 1..." +run_in $MOUNT1 mkdir -p $TEST_DIR/deep/nested +run_in $MOUNT1 sh -c "echo 'Direct access content' > $TEST_DIR/deep/nested/target.txt" +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/deep/nested/link.txt +sync_and_wait + +log_info "Verifying symlink exists via S3 API..." +SYMLINKS_S3=$(get_symlinks_file_s3 "test7/deep/nested") +if [ -n "$SYMLINKS_S3" ]; then + log_pass ".geesefs_symlinks file exists in S3 for nested dir" + echo " Content:" + echo "$SYMLINKS_S3" | sed 's/^/ /' +else + log_fail ".geesefs_symlinks file not found in S3 for nested dir" +fi + +log_info "Waiting for cache refresh in container 2..." +sleep 5 + +log_info "Directly reading symlink content from container 2 (no ls or cd first)..." +CONTENT_DIRECT=$(run_in $MOUNT2 cat $TEST_DIR/deep/nested/link.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT_DIRECT" = "Direct access content" ]; then + log_pass "Symlink in subfolder accessible without listing directory first (container 2)" +else + log_fail "Symlink in subfolder NOT accessible without listing directory (got: $CONTENT_DIRECT)" +fi + +log_info "Directly checking symlink type from container 2 (no ls or cd first)..." +if run_in $MOUNT2 test -L $TEST_DIR/deep/nested/link.txt; then + log_pass "Symlink is recognized as symlink without listing directory first" +else + log_fail "Symlink not recognized as symlink without listing directory first" +fi + +log_info "Directly reading symlink target from container 2 (no ls or cd first)..." +TARGET_DIRECT=$(run_in $MOUNT2 readlink $TEST_DIR/deep/nested/link.txt 2>/dev/null || echo "FAILED") +if [ "$TARGET_DIRECT" = "target.txt" ]; then + log_pass "Symlink target correct without listing directory first" +else + log_fail "Symlink target incorrect without listing directory (got: $TARGET_DIRECT)" +fi + +cleanup_test_folder 7 + # ============================================================================== log_header "TEST SUMMARY" # ============================================================================== From 30ff73204893e769c0b07d4ebba13e17cbf8558b Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 13:25:32 +0100 Subject: [PATCH 13/39] fix: deep copy symlinks cache before mutation to prevent corruption on save failure updateSymlinksFile() was mutating the shared symlinksCache in place before confirming the S3 save succeeded. If SaveSymlinksFileWithRetry failed, the in-memory cache would contain changes never persisted, causing phantom symlinks or missed deletions. Co-Authored-By: Claude Opus 4.6 --- core/dir.go | 9 ++++---- core/symlinks.go | 12 ++++++++++ core/symlinks_test.go | 53 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/core/dir.go b/core/dir.go index 547d7021..c11b6d7d 100644 --- a/core/dir.go +++ b/core/dir.go @@ -1667,13 +1667,14 @@ func (parent *Inode) updateSymlinksFile(name string, target string, remove bool) symlinksFileName := parent.fs.flags.SymlinksFile - // Load or use cached symlinks data + // Load or use cached symlinks data — always work on a deep copy + // so the in-memory cache is not corrupted if the save fails. var data *SymlinksFileData var etag string var err error if parent.dir.symlinksCache != nil { - data = parent.dir.symlinksCache + data = parent.dir.symlinksCache.DeepCopy() etag = parent.dir.symlinksCacheETag } else { data, etag, err = LoadSymlinksFile(cloud, dirKey, symlinksFileName) @@ -1682,7 +1683,7 @@ func (parent *Inode) updateSymlinksFile(name string, target string, remove bool) } } - // Update the data + // Update the copy (original cache is untouched) if remove { data.RemoveSymlink(name) } else { @@ -1707,7 +1708,7 @@ func (parent *Inode) updateSymlinksFile(name string, target string, remove bool) return err } - // Update cache - reload to get the merged state if there was a conflict + // Only update cache after successful save parent.dir.symlinksCache = data parent.dir.symlinksCacheETag = newETag parent.dir.symlinksCacheTime = time.Now() diff --git a/core/symlinks.go b/core/symlinks.go index 71d1cd81..3012ac32 100644 --- a/core/symlinks.go +++ b/core/symlinks.go @@ -109,6 +109,18 @@ func (s *SymlinksFileData) IsEmpty() bool { return len(s.Symlinks) == 0 } +// DeepCopy returns a deep copy of the symlinks data +func (s *SymlinksFileData) DeepCopy() *SymlinksFileData { + copy := &SymlinksFileData{ + Version: s.Version, + Symlinks: make(map[string]SymlinkEntry, len(s.Symlinks)), + } + for k, v := range s.Symlinks { + copy.Symlinks[k] = v + } + return copy +} + // getSymlinksFilePath returns the full key path for the .symlinks file in a directory func getSymlinksFilePath(dirKey string, symlinksFileName string) string { if dirKey == "" { diff --git a/core/symlinks_test.go b/core/symlinks_test.go index 12594cfb..8a7d9a6d 100644 --- a/core/symlinks_test.go +++ b/core/symlinks_test.go @@ -98,6 +98,59 @@ func (s *SymlinksTest) TestParseInvalidJSON(t *C) { t.Assert(err, NotNil) } +func (s *SymlinksTest) TestDeepCopy(t *C) { + original := NewSymlinksFileData() + original.AddSymlink("link1", "../target1") + original.AddSymlink("link2", "../target2") + + copied := original.DeepCopy() + + // Verify copy has the same data + t.Assert(copied.Version, Equals, original.Version) + t.Assert(len(copied.Symlinks), Equals, 2) + target, ok := copied.GetSymlink("link1") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "../target1") + + // Mutate the copy and verify original is unchanged + copied.AddSymlink("link3", "../target3") + copied.RemoveSymlink("link1") + + t.Assert(original.HasSymlink("link1"), Equals, true) + t.Assert(original.HasSymlink("link3"), Equals, false) + t.Assert(len(original.Symlinks), Equals, 2) + + t.Assert(copied.HasSymlink("link1"), Equals, false) + t.Assert(copied.HasSymlink("link3"), Equals, true) + t.Assert(len(copied.Symlinks), Equals, 2) +} + +func (s *SymlinksTest) TestUpdateFailureDoesNotCorruptCache(t *C) { + mock := newMockConditionalBackend() + + // Pre-create a symlinks file + data := NewSymlinksFileData() + data.AddSymlink("existing-link", "../existing-target") + etag, err := SaveSymlinksFile(mock, "testdir", ".geesefs_symlinks", data, "") + t.Assert(err, IsNil) + + // Simulate a cache: deep copy before mutating + cached := data.DeepCopy() + workingCopy := cached.DeepCopy() + workingCopy.AddSymlink("new-link", "../new-target") + + // Simulate a failed save (wrong ETag) + _, err = SaveSymlinksFile(mock, "testdir", ".geesefs_symlinks", workingCopy, "\"wrong-etag\"") + t.Assert(err, NotNil) + + // Original cache must be untouched + t.Assert(cached.HasSymlink("existing-link"), Equals, true) + t.Assert(cached.HasSymlink("new-link"), Equals, false) + t.Assert(len(cached.Symlinks), Equals, 1) + + _ = etag +} + func (s *SymlinksTest) TestGetSymlinksFilePath(t *C) { t.Assert(getSymlinksFilePath("", ".geesefs_symlinks"), Equals, ".geesefs_symlinks") t.Assert(getSymlinksFilePath("dir", ".geesefs_symlinks"), Equals, "dir/.geesefs_symlinks") From 9802f2e2b9493076b37fe0423d645efe301f242a Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 13:39:50 +0100 Subject: [PATCH 14/39] fix: release parent.mu during symlinks I/O to prevent lock contention loadSymlinksCache() and updateSymlinksFile() were holding parent.mu during S3 network calls, blocking all concurrent FUSE operations on the same directory. Follow the existing GeeseFS convention (loadListing, listObjectsFlat) of temporarily releasing the lock during I/O. Co-Authored-By: Claude Opus 4.6 --- core/dir.go | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/core/dir.go b/core/dir.go index c11b6d7d..654fead6 100644 --- a/core/dir.go +++ b/core/dir.go @@ -1654,8 +1654,9 @@ func (parent *Inode) CreateSymlink( return inode, nil } -// updateSymlinksFile updates the .symlinks file in this directory +// updateSymlinksFile updates the .symlinks file in this directory. // LOCKS_REQUIRED(parent.mu) +// Note: temporarily releases parent.mu during network I/O. func (parent *Inode) updateSymlinksFile(name string, target string, remove bool) error { cloud, dirKey := parent.cloud() if cloud == nil { @@ -1677,7 +1678,10 @@ func (parent *Inode) updateSymlinksFile(name string, target string, remove bool) data = parent.dir.symlinksCache.DeepCopy() etag = parent.dir.symlinksCacheETag } else { + // Need to load from cloud — release lock during I/O + parent.mu.Unlock() data, etag, err = LoadSymlinksFile(cloud, dirKey, symlinksFileName) + parent.mu.Lock() if err != nil { return err } @@ -1701,9 +1705,11 @@ func (parent *Inode) updateSymlinksFile(name string, target string, remove bool) return currentData, nil } - // Save with conditional write and retry on conflict + // Release lock during save I/O (may retry with exponential backoff) const maxRetries = 5 + parent.mu.Unlock() newETag, err := SaveSymlinksFileWithRetry(cloud, dirKey, symlinksFileName, data, etag, mergeFn, maxRetries) + parent.mu.Lock() if err != nil { return err } @@ -1716,8 +1722,9 @@ func (parent *Inode) updateSymlinksFile(name string, target string, remove bool) return nil } -// loadSymlinksCache loads the symlinks file cache for this directory if needed +// loadSymlinksCache loads the symlinks file cache for this directory if needed. // LOCKS_REQUIRED(parent.mu) +// Note: temporarily releases parent.mu during network I/O. func (parent *Inode) loadSymlinksCache() error { if !parent.fs.flags.EnableSymlinksFile { return nil @@ -1731,9 +1738,14 @@ func (parent *Inode) loadSymlinksCache() error { dirKey = strings.TrimSuffix(dirKey, "/") symlinksFileName := parent.fs.flags.SymlinksFile - // Use conditional GET with cached ETag to check if file has changed + // Capture cached ETag before releasing lock cachedETag := parent.dir.symlinksCacheETag + + // Release lock during network I/O (conditional GET) + parent.mu.Unlock() data, etag, err := LoadSymlinksFileConditional(cloud, dirKey, symlinksFileName, cachedETag) + parent.mu.Lock() + if err != nil { return err } From bf6c28acf6b6c6efaa6e648b9933890466862d13 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 13:55:11 +0100 Subject: [PATCH 15/39] fix: add TTL check to loadSymlinksCache to avoid excessive S3 requests loadSymlinksCache() was issuing a conditional GET on every call with no TTL guard. An ls -la on 100 files could trigger 100+ S3 requests for the same symlinks file. Now skips the network call if the cache was loaded within StatCacheTTL. Co-Authored-By: Claude Opus 4.6 --- core/dir.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/core/dir.go b/core/dir.go index 654fead6..21ae4eee 100644 --- a/core/dir.go +++ b/core/dir.go @@ -1723,6 +1723,7 @@ func (parent *Inode) updateSymlinksFile(name string, target string, remove bool) } // loadSymlinksCache loads the symlinks file cache for this directory if needed. +// Skips the network call if the cache was loaded recently (within StatCacheTTL). // LOCKS_REQUIRED(parent.mu) // Note: temporarily releases parent.mu during network I/O. func (parent *Inode) loadSymlinksCache() error { @@ -1730,6 +1731,12 @@ func (parent *Inode) loadSymlinksCache() error { return nil } + // Skip if cache was recently loaded (within StatCacheTTL) + if parent.dir.symlinksCache != nil && + !expired(parent.dir.symlinksCacheTime, parent.fs.flags.StatCacheTTL) { + return nil + } + cloud, dirKey := parent.cloud() if cloud == nil { return syscall.ESTALE From f10a88b2130dd5aeab8c09a759f30348eaa519ea Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 14:00:10 +0100 Subject: [PATCH 16/39] fix: restore --symlink-attr CLI flag removed by symlinks-file PR The --symlink-attr flag was accidentally removed when adding the new --enable-symlinks-file flags. This left SymlinkAttr as "" for all CLI-launched mounts, breaking symlink detection via the old metadata path. Restore the flag and wire it back into PopulateFlags. Co-Authored-By: Claude Opus 4.6 --- core/cfg/flags.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/cfg/flags.go b/core/cfg/flags.go index c26e9b60..ddcb3ccc 100644 --- a/core/cfg/flags.go +++ b/core/cfg/flags.go @@ -558,6 +558,13 @@ MISC OPTIONS: Usage: "File modification time (UNIX time) metadata attribute name", }, + cli.StringFlag{ + Name: "symlink-attr", + Value: "--symlink-target", + Usage: "Symbolic link target metadata attribute name." + + " Only works correctly if your S3 returns UserMetadata in listings", + }, + cli.BoolFlag{ Name: "enable-symlinks-file", Usage: "Store symlinks in a hidden .symlinks file per directory instead of object metadata." + @@ -877,6 +884,7 @@ func PopulateFlags(c *cli.Context) (ret *FlagStorage) { FileModeAttr: c.String("mode-attr"), RdevAttr: c.String("rdev-attr"), MtimeAttr: c.String("mtime-attr"), + SymlinkAttr: c.String("symlink-attr"), SymlinksFile: c.String("symlinks-file"), EnableSymlinksFile: c.Bool("enable-symlinks-file"), HideSymlinksFile: c.Bool("hide-symlinks-file"), From 99e3fdb6ec484b2f7739a5ea019c60328d87a519 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 14:08:02 +0100 Subject: [PATCH 17/39] fix: use AWS SDK typed errors instead of fragile string matching isNotExist, isNotModified, and isPreconditionFailed were using strings.Contains on error messages (e.g., "404", "412"), which could false-match unrelated errors. Now check awserr.RequestFailure status codes first, with tight fallbacks for non-AWS backends. Co-Authored-By: Claude Opus 4.6 --- core/symlinks.go | 44 +++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/core/symlinks.go b/core/symlinks.go index 3012ac32..e23dfdac 100644 --- a/core/symlinks.go +++ b/core/symlinks.go @@ -23,6 +23,8 @@ import ( "sync" "syscall" "time" + + "github.com/aws/aws-sdk-go/aws/awserr" ) // SymlinkEntry represents a single symlink in the .symlinks file @@ -225,10 +227,15 @@ func isNotModified(err error) bool { if err == nil { return false } - errStr := err.Error() - return strings.Contains(errStr, "304") || - strings.Contains(errStr, "NotModified") || - strings.Contains(errStr, "Not Modified") + // AWS SDK typed error (real S3 backend) + if reqErr, ok := err.(awserr.RequestFailure); ok { + return reqErr.StatusCode() == 304 + } + if awsErr, ok := err.(awserr.Error); ok { + return awsErr.Code() == "NotModified" + } + // Fallback for non-AWS backends (e.g., mock in tests) + return err.Error() == "304 Not Modified" } @@ -299,11 +306,17 @@ func isPreconditionFailed(err error) bool { if err == nil { return false } + // AWS SDK typed error (real S3 backend) + if reqErr, ok := err.(awserr.RequestFailure); ok { + return reqErr.StatusCode() == 412 + } + if awsErr, ok := err.(awserr.Error); ok { + return awsErr.Code() == "PreconditionFailed" + } + // Fallback for non-AWS backends (e.g., mock in tests): + // match errors that start with "PreconditionFailed" errStr := err.Error() - return strings.Contains(errStr, "PreconditionFailed") || - strings.Contains(errStr, "412") || - strings.Contains(errStr, "Precondition Failed") || - strings.Contains(errStr, "conditional request failed") + return len(errStr) >= 18 && errStr[:18] == "PreconditionFailed" } // SaveSymlinksFileWithRetry saves the .symlinks file with automatic retry on conflict. @@ -403,14 +416,15 @@ func isNotExist(err error) bool { if err == nil { return false } - // Check for syscall error if err == syscall.ENOENT { return true } - errStr := err.Error() - return strings.Contains(errStr, "NoSuchKey") || - strings.Contains(errStr, "NotFound") || - strings.Contains(errStr, "404") || - strings.Contains(errStr, "does not exist") || - strings.Contains(errStr, "no such file or directory") + // AWS SDK typed error (real S3 backend) + if reqErr, ok := err.(awserr.RequestFailure); ok { + return reqErr.StatusCode() == 404 + } + if awsErr, ok := err.(awserr.Error); ok { + return awsErr.Code() == "NoSuchKey" || awsErr.Code() == "NotFound" + } + return false } From c2e2ce4d3deab08a33b3433bdc3f82a7294b1cb0 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 14:12:23 +0100 Subject: [PATCH 18/39] test: add tests for AWS SDK typed error detection paths The previous commit changed error detection to use awserr.RequestFailure status codes, but existing tests only exercised the fallback paths via the mock backend. Add tests covering the real S3 paths (awserr typed errors) and verify no false positives on unrelated error messages. Co-Authored-By: Claude Opus 4.6 --- core/symlinks_test.go | 72 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/core/symlinks_test.go b/core/symlinks_test.go index 8a7d9a6d..5ec5ccad 100644 --- a/core/symlinks_test.go +++ b/core/symlinks_test.go @@ -16,7 +16,9 @@ package core import ( "fmt" + "syscall" + "github.com/aws/aws-sdk-go/aws/awserr" . "gopkg.in/check.v1" ) @@ -754,3 +756,73 @@ func (s *SymlinksTest) TestSymlinksCacheDeleteMergesCorrectly(t *C) { t.Assert(cloudData.HasSymlink("link2"), Equals, true) } +// ============================================================================ +// Tests for error detection functions (AWS SDK typed errors + fallbacks) +// ============================================================================ + +func (s *SymlinksTest) TestIsNotExistWithAwsRequestFailure(t *C) { + // Real S3 backend returns awserr.RequestFailure with status 404 + err := awserr.NewRequestFailure(awserr.New("NoSuchKey", "The specified key does not exist.", nil), 404, "req-123") + t.Assert(isNotExist(err), Equals, true) +} + +func (s *SymlinksTest) TestIsNotExistWithAwsErrorCode(t *C) { + err := awserr.New("NoSuchKey", "key not found", nil) + t.Assert(isNotExist(err), Equals, true) + + err = awserr.New("NotFound", "not found", nil) + t.Assert(isNotExist(err), Equals, true) +} + +func (s *SymlinksTest) TestIsNotExistWithSyscallENOENT(t *C) { + t.Assert(isNotExist(syscall.ENOENT), Equals, true) +} + +func (s *SymlinksTest) TestIsNotExistWithNilAndUnrelated(t *C) { + t.Assert(isNotExist(nil), Equals, false) + t.Assert(isNotExist(fmt.Errorf("error at line 404")), Equals, false) + t.Assert(isNotExist(fmt.Errorf("network timeout")), Equals, false) +} + +func (s *SymlinksTest) TestIsPreconditionFailedWithAwsRequestFailure(t *C) { + err := awserr.NewRequestFailure(awserr.New("PreconditionFailed", "At least one of the pre-conditions you specified did not hold", nil), 412, "req-456") + t.Assert(isPreconditionFailed(err), Equals, true) +} + +func (s *SymlinksTest) TestIsPreconditionFailedWithAwsErrorCode(t *C) { + err := awserr.New("PreconditionFailed", "precondition failed", nil) + t.Assert(isPreconditionFailed(err), Equals, true) +} + +func (s *SymlinksTest) TestIsPreconditionFailedWithMockError(t *C) { + // Mock backend returns plain fmt.Errorf starting with "PreconditionFailed" + t.Assert(isPreconditionFailed(fmt.Errorf("PreconditionFailed: object already exists")), Equals, true) + t.Assert(isPreconditionFailed(fmt.Errorf("PreconditionFailed: ETag mismatch")), Equals, true) +} + +func (s *SymlinksTest) TestIsPreconditionFailedWithNilAndUnrelated(t *C) { + t.Assert(isPreconditionFailed(nil), Equals, false) + t.Assert(isPreconditionFailed(fmt.Errorf("error code 412 in config")), Equals, false) + t.Assert(isPreconditionFailed(fmt.Errorf("network timeout")), Equals, false) +} + +func (s *SymlinksTest) TestIsNotModifiedWithAwsRequestFailure(t *C) { + err := awserr.NewRequestFailure(awserr.New("NotModified", "Not Modified", nil), 304, "req-789") + t.Assert(isNotModified(err), Equals, true) +} + +func (s *SymlinksTest) TestIsNotModifiedWithAwsErrorCode(t *C) { + err := awserr.New("NotModified", "not modified", nil) + t.Assert(isNotModified(err), Equals, true) +} + +func (s *SymlinksTest) TestIsNotModifiedWithMockError(t *C) { + t.Assert(isNotModified(fmt.Errorf("304 Not Modified")), Equals, true) +} + +func (s *SymlinksTest) TestIsNotModifiedWithNilAndUnrelated(t *C) { + t.Assert(isNotModified(nil), Equals, false) + t.Assert(isNotModified(fmt.Errorf("error 304 in processing")), Equals, false) + t.Assert(isNotModified(fmt.Errorf("network timeout")), Equals, false) +} + From d187ac6309b2e6dba1d865dd10f993218da743c3 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 14:35:22 +0100 Subject: [PATCH 19/39] fix: update .geesefs_symlinks on symlink rename (same-dir and cross-dir) Renaming a virtual symlink now properly removes the entry from the source directory's .geesefs_symlinks and adds it to the destination's, ensuring cross-mount visibility. Adds docker integration tests for both cases. Co-Authored-By: Claude Opus 4.6 --- core/dir.go | 37 +++++ .../tests/symlinks_file/test_symlinks_file.sh | 156 ++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/core/dir.go b/core/dir.go index 21ae4eee..7793b6d9 100644 --- a/core/dir.go +++ b/core/dir.go @@ -2012,6 +2012,43 @@ func (parent *Inode) Rename(from string, newParent *Inode, to string) (err error } } + // Handle virtual symlink rename: update .geesefs_symlinks files, no S3 object to move + if parent.fs.flags.EnableSymlinksFile { + isSymlink := fromInode.userMetadata != nil && fromInode.userMetadata[parent.fs.flags.SymlinkAttr] != nil + if isSymlink { + target := string(fromInode.userMetadata[parent.fs.flags.SymlinkAttr]) + + // Remove from source directory's symlinks file + if err := parent.updateSymlinksFile(from, "", true); err != nil { + s3Log.Warnf("Failed to update source symlinks file for rename %v: %v", from, err) + } + + // Add to destination directory's symlinks file + if err := newParent.updateSymlinksFile(to, target, false); err != nil { + return err + } + + // If target inode exists at destination, clean it up + if toInode != nil { + toInode.mu.Lock() + newParent.removeChildUnlocked(toInode) + toInode.resetCache() + toInode.SetCacheState(ST_DEAD) + toInode.mu.Unlock() + } + + // Move the inode in the tree (ref/deref to keep counts balanced) + fromInode.Ref() + parent.removeChildUnlocked(fromInode) + fromInode.Name = to + fromInode.Parent = newParent + newParent.insertChildUnlocked(fromInode) + fromInode.DeRef(1) + + return nil + } + } + fromFullName := appendChildName(fromPath, from) toFullName := appendChildName(toPath, to) diff --git a/docker/tests/symlinks_file/test_symlinks_file.sh b/docker/tests/symlinks_file/test_symlinks_file.sh index 6009246a..c72a07bf 100755 --- a/docker/tests/symlinks_file/test_symlinks_file.sh +++ b/docker/tests/symlinks_file/test_symlinks_file.sh @@ -486,6 +486,162 @@ fi cleanup_test_folder 7 +# ============================================================================== +log_header "TEST 8: Rename symlink within same directory" +# ============================================================================== + +setup_test_folder 8 + +log_info "Creating target file and symlink..." +run_in $MOUNT1 sh -c "echo 'Rename test content' > $TEST_DIR/target.txt" +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/oldname.txt +sync_and_wait + +log_info "Verifying symlink before rename..." +CONTENT_BEFORE=$(run_in $MOUNT1 cat $TEST_DIR/oldname.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT_BEFORE" = "Rename test content" ]; then + log_pass "Symlink works before rename" +else + log_fail "Symlink doesn't work before rename (got: $CONTENT_BEFORE)" +fi + +log_info "Checking .geesefs_symlinks before rename..." +SYMLINKS_BEFORE=$(get_symlinks_file_s3 "test8") +echo " Content before rename:" +echo "$SYMLINKS_BEFORE" | sed 's/^/ /' + +log_info "Renaming symlink: oldname.txt -> newname.txt" +run_in $MOUNT1 mv $TEST_DIR/oldname.txt $TEST_DIR/newname.txt +sync_and_wait + +log_info "Verifying renamed symlink works..." +CONTENT_AFTER=$(run_in $MOUNT1 cat $TEST_DIR/newname.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT_AFTER" = "Rename test content" ]; then + log_pass "Renamed symlink works in container 1" +else + log_fail "Renamed symlink doesn't work in container 1 (got: $CONTENT_AFTER)" +fi + +log_info "Verifying old name no longer exists..." +if run_in $MOUNT1 test -e $TEST_DIR/oldname.txt 2>/dev/null; then + log_fail "Old symlink name still exists after rename" +else + log_pass "Old symlink name removed after rename" +fi + +log_info "Checking .geesefs_symlinks after rename..." +SYMLINKS_AFTER=$(get_symlinks_file_s3 "test8") +echo " Content after rename:" +echo "$SYMLINKS_AFTER" | sed 's/^/ /' + +if echo "$SYMLINKS_AFTER" | grep -q "newname.txt"; then + log_pass ".geesefs_symlinks contains newname.txt" +else + log_fail ".geesefs_symlinks missing newname.txt" +fi + +if echo "$SYMLINKS_AFTER" | grep -q "oldname.txt"; then + log_fail ".geesefs_symlinks still contains oldname.txt" +else + log_pass ".geesefs_symlinks no longer contains oldname.txt" +fi + +log_info "Waiting for cache refresh in container 2..." +sleep 5 +run_in $MOUNT2 ls -la $TEST_DIR/ >/dev/null 2>&1 || true +sleep 2 + +log_info "Checking renamed symlink visibility in container 2..." +CONTENT_M2=$(run_in $MOUNT2 cat $TEST_DIR/newname.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT_M2" = "Rename test content" ]; then + log_pass "Renamed symlink visible and works in container 2" +else + log_fail "Renamed symlink not working in container 2 (got: $CONTENT_M2)" +fi + +cleanup_test_folder 8 + +# ============================================================================== +log_header "TEST 9: Rename symlink to different directory" +# ============================================================================== + +setup_test_folder 9 + +log_info "Creating source and destination directories..." +run_in $MOUNT1 mkdir -p $TEST_DIR/srcdir +run_in $MOUNT1 mkdir -p $TEST_DIR/dstdir +run_in $MOUNT1 sh -c "echo 'Cross-dir rename content' > $TEST_DIR/srcdir/target.txt" +run_in $MOUNT1 sh -c "echo 'Cross-dir rename content' > $TEST_DIR/dstdir/target.txt" +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/srcdir/link.txt +sync_and_wait + +log_info "Verifying symlink in source dir before rename..." +CONTENT_SRC=$(run_in $MOUNT1 cat $TEST_DIR/srcdir/link.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT_SRC" = "Cross-dir rename content" ]; then + log_pass "Symlink works in source dir before rename" +else + log_fail "Symlink doesn't work in source dir (got: $CONTENT_SRC)" +fi + +log_info "Checking source .geesefs_symlinks before rename..." +SRC_SYMLINKS=$(get_symlinks_file_s3 "test9/srcdir") +echo " Source before rename:" +echo "$SRC_SYMLINKS" | sed 's/^/ /' + +log_info "Renaming symlink from srcdir to dstdir..." +run_in $MOUNT1 mv $TEST_DIR/srcdir/link.txt $TEST_DIR/dstdir/link.txt +sync_and_wait + +log_info "Verifying symlink removed from source dir..." +if run_in $MOUNT1 test -e $TEST_DIR/srcdir/link.txt 2>/dev/null; then + log_fail "Symlink still exists in source dir after rename" +else + log_pass "Symlink removed from source dir after rename" +fi + +log_info "Verifying symlink works in destination dir..." +CONTENT_DST=$(run_in $MOUNT1 cat $TEST_DIR/dstdir/link.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT_DST" = "Cross-dir rename content" ]; then + log_pass "Renamed symlink works in destination dir" +else + log_fail "Renamed symlink doesn't work in destination dir (got: $CONTENT_DST)" +fi + +log_info "Checking .geesefs_symlinks in source dir after rename..." +SRC_AFTER=$(get_symlinks_file_s3 "test9/srcdir") +if [ -z "$SRC_AFTER" ] || ! echo "$SRC_AFTER" | grep -q "link.txt"; then + log_pass "Source .geesefs_symlinks no longer contains link.txt" +else + log_fail "Source .geesefs_symlinks still contains link.txt" + echo " Content:" + echo "$SRC_AFTER" | sed 's/^/ /' +fi + +log_info "Checking .geesefs_symlinks in destination dir after rename..." +DST_AFTER=$(get_symlinks_file_s3 "test9/dstdir") +if echo "$DST_AFTER" | grep -q "link.txt"; then + log_pass "Destination .geesefs_symlinks contains link.txt" +else + log_fail "Destination .geesefs_symlinks missing link.txt" +fi +echo " Content:" +echo "$DST_AFTER" | sed 's/^/ /' + +log_info "Waiting for cache refresh in container 2..." +sleep 5 +run_in $MOUNT2 ls -la $TEST_DIR/dstdir/ >/dev/null 2>&1 || true +sleep 2 + +log_info "Checking cross-dir renamed symlink visibility in container 2..." +CONTENT_M2=$(run_in $MOUNT2 cat $TEST_DIR/dstdir/link.txt 2>/dev/null || echo "FAILED") +if [ "$CONTENT_M2" = "Cross-dir rename content" ]; then + log_pass "Cross-dir renamed symlink visible and works in container 2" +else + log_fail "Cross-dir renamed symlink not working in container 2 (got: $CONTENT_M2)" +fi + +cleanup_test_folder 9 + # ============================================================================== log_header "TEST SUMMARY" # ============================================================================== From a701fc190ad5fa198bb5a917952749b74d757e42 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 15:06:48 +0100 Subject: [PATCH 20/39] fix: use isVirtualSymlink field to distinguish virtual from S3-backed symlinks The previous check (userMetadata[SymlinkAttr] != nil) could not distinguish virtual symlinks (stored in .geesefs_symlinks, no S3 object) from S3-backed symlinks (created via the old metadata path). This could cause orphaned S3 objects on unlink or incorrect behavior on rename. Adds isVirtualSymlink bool to Inode, set at all virtual symlink creation sites and checked in Unlink, Rename, and listing cleanup paths. --- core/dir.go | 26 ++++++++++++++++++-------- core/handles.go | 7 ++++--- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/core/dir.go b/core/dir.go index 7793b6d9..4cee7bb1 100644 --- a/core/dir.go +++ b/core/dir.go @@ -469,6 +469,7 @@ func (dh *DirHandle) handleListResult(resp *ListBlobsOutput, prefix string, skip inode.userMetadata = make(map[string][]byte) } inode.userMetadata[fs.flags.SymlinkAttr] = []byte(target) + inode.isVirtualSymlink = true inode.mu.Unlock() } } @@ -487,6 +488,7 @@ func (dh *DirHandle) handleListResult(resp *ListBlobsOutput, prefix string, skip inode.userMetadata = make(map[string][]byte) } inode.userMetadata[fs.flags.SymlinkAttr] = []byte(target) + inode.isVirtualSymlink = true inode.mu.Unlock() } } @@ -544,6 +546,7 @@ func (parent *Inode) createVirtualSymlinksFromCache() []interface{} { wasSymlink := oldTarget != "" // Always update the symlink target from the cache (another mount may have changed it) existingInode.userMetadata[fs.flags.SymlinkAttr] = []byte(entry.Target) + existingInode.isVirtualSymlink = true existingInode.mu.Unlock() s3Log.Debugf("createVirtualSymlinksFromCache: updated existing inode %v, oldTarget=%q newTarget=%q wasSymlink=%v", name, oldTarget, entry.Target, wasSymlink) @@ -564,6 +567,7 @@ func (parent *Inode) createVirtualSymlinksFromCache() []interface{} { inode := NewInode(fs, parent, name) inode.userMetadata = make(map[string][]byte) inode.userMetadata[fs.flags.SymlinkAttr] = []byte(entry.Target) + inode.isVirtualSymlink = true inode.Attributes = InodeAttributes{ Size: 0, Mtime: time.Unix(entry.Mtime, 0), @@ -608,7 +612,7 @@ func (parent *Inode) syncVirtualSymlinksFromCache() { for i := 0; i < len(parent.dir.Children); i++ { child := parent.dir.Children[i] child.mu.Lock() - isVirtual := child.userMetadata != nil && child.userMetadata[fs.flags.SymlinkAttr] != nil && + isVirtual := child.isVirtualSymlink && atomic.LoadInt32(&child.CacheState) == ST_CACHED child.mu.Unlock() if !isVirtual { @@ -959,7 +963,7 @@ func (parent *Inode) removeExpired(from string) { // check if they still exist in the symlinks cache if parent.fs.flags.EnableSymlinksFile { childTmp.mu.Lock() - isVirtualSymlink := childTmp.userMetadata != nil && childTmp.userMetadata[parent.fs.flags.SymlinkAttr] != nil + isVirtualSymlink := childTmp.isVirtualSymlink childTmp.mu.Unlock() if isVirtualSymlink { // Check if symlink still exists in cache (cache was refreshed in loadListing) @@ -1343,12 +1347,12 @@ func (parent *Inode) Unlink(name string) (err error) { if inode != nil { fuseLog.Debugf("Unlink %v", inode.FullName()) - // If this is a symlink and using symlinks file, update the file + // If this is a virtual symlink (stored in .geesefs_symlinks, no S3 object), update the file if parent.fs.flags.EnableSymlinksFile { inode.mu.Lock() - isSymlink := inode.userMetadata != nil && inode.userMetadata[parent.fs.flags.SymlinkAttr] != nil + isVirtual := inode.isVirtualSymlink inode.mu.Unlock() - if isSymlink { + if isVirtual { if err := parent.updateSymlinksFile(name, "", true); err != nil { s3Log.Warnf("Failed to update symlinks file for unlink %v: %v", name, err) // Continue anyway, the symlink entry will be orphaned but the file will be deleted @@ -1623,6 +1627,7 @@ func (parent *Inode) CreateSymlink( return nil, err } inode.userMetadataDirty = 0 + inode.isVirtualSymlink = true } else { inode.userMetadataDirty = 2 } @@ -2014,8 +2019,7 @@ func (parent *Inode) Rename(from string, newParent *Inode, to string) (err error // Handle virtual symlink rename: update .geesefs_symlinks files, no S3 object to move if parent.fs.flags.EnableSymlinksFile { - isSymlink := fromInode.userMetadata != nil && fromInode.userMetadata[parent.fs.flags.SymlinkAttr] != nil - if isSymlink { + if fromInode.isVirtualSymlink { target := string(fromInode.userMetadata[parent.fs.flags.SymlinkAttr]) // Remove from source directory's symlinks file @@ -2342,8 +2346,9 @@ func (parent *Inode) LookUpCached(name string) (inode *Inode, err error) { if inode != nil { inode.mu.Lock() hasSymlinkAttr := inode.userMetadata != nil && inode.userMetadata[parent.fs.flags.SymlinkAttr] != nil + isVirtual := inode.isVirtualSymlink inode.mu.Unlock() - s3Log.Debugf("LookUpCached: inode=%v hasSymlinkAttr=%v", inode.Name, hasSymlinkAttr) + s3Log.Debugf("LookUpCached: inode=%v hasSymlinkAttr=%v isVirtualSymlink=%v", inode.Name, hasSymlinkAttr, isVirtual) } } @@ -2383,6 +2388,7 @@ func (parent *Inode) LookUpCached(name string) (inode *Inode, err error) { inode = NewInode(parent.fs, parent, name) inode.userMetadata = make(map[string][]byte) inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) + inode.isVirtualSymlink = true now := time.Now() inode.Attributes = InodeAttributes{ Size: 0, @@ -2455,6 +2461,7 @@ func (parent *Inode) LookUp(name string, doSlurp bool) (*Inode, error) { inode = NewInode(parent.fs, parent, name) inode.userMetadata = make(map[string][]byte) inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) + inode.isVirtualSymlink = true now := time.Now() inode.Attributes = InodeAttributes{ Size: 0, @@ -2473,6 +2480,7 @@ func (parent *Inode) LookUp(name string, doSlurp bool) (*Inode, error) { inode.userMetadata = make(map[string][]byte) } inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) + inode.isVirtualSymlink = true inode.SetAttrTime(time.Now()) inode.mu.Unlock() } @@ -2498,6 +2506,7 @@ func (parent *Inode) LookUp(name string, doSlurp bool) (*Inode, error) { inode = NewInode(parent.fs, parent, name) inode.userMetadata = make(map[string][]byte) inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) + inode.isVirtualSymlink = true now := time.Now() inode.Attributes = InodeAttributes{ Size: 0, @@ -2516,6 +2525,7 @@ func (parent *Inode) LookUp(name string, doSlurp bool) (*Inode, error) { inode.userMetadata = make(map[string][]byte) } inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) + inode.isVirtualSymlink = true inode.SetAttrTime(time.Now()) inode.mu.Unlock() } diff --git a/core/handles.go b/core/handles.go index 543ce761..57052517 100644 --- a/core/handles.go +++ b/core/handles.go @@ -131,9 +131,10 @@ type Inode struct { // multipart upload state mpu *MultipartBlobCommitInput - userMetadataDirty int - userMetadata map[string][]byte - s3Metadata map[string][]byte + userMetadataDirty int + userMetadata map[string][]byte + s3Metadata map[string][]byte + isVirtualSymlink bool // true if symlink is stored in .geesefs_symlinks (no S3 object) // last known size and etag from the cloud knownSize uint64 From 03d27320a34bb91eaf03abd67521c9fc716e983a Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 15:26:27 +0100 Subject: [PATCH 21/39] refactor: extract virtual symlink helpers to reduce duplication Extract newVirtualSymlinkInode() and applyVirtualSymlinkAttrs() helpers to replace 9 duplicate virtual symlink creation/update blocks. --- core/dir.go | 136 ++++++++++++++++++---------------------------------- 1 file changed, 46 insertions(+), 90 deletions(-) diff --git a/core/dir.go b/core/dir.go index 4cee7bb1..aea47f56 100644 --- a/core/dir.go +++ b/core/dir.go @@ -465,11 +465,7 @@ func (dh *DirHandle) handleListResult(resp *ListBlobsOutput, prefix string, skip if fs.flags.EnableSymlinksFile { if target, ok := parent.getSymlinkTargetFromCache(baseName); ok { inode.mu.Lock() - if inode.userMetadata == nil { - inode.userMetadata = make(map[string][]byte) - } - inode.userMetadata[fs.flags.SymlinkAttr] = []byte(target) - inode.isVirtualSymlink = true + inode.applyVirtualSymlinkAttrs(target) inode.mu.Unlock() } } @@ -484,11 +480,7 @@ func (dh *DirHandle) handleListResult(resp *ListBlobsOutput, prefix string, skip if fs.flags.EnableSymlinksFile { if target, ok := parent.getSymlinkTargetFromCache(baseName); ok { inode.mu.Lock() - if inode.userMetadata == nil { - inode.userMetadata = make(map[string][]byte) - } - inode.userMetadata[fs.flags.SymlinkAttr] = []byte(target) - inode.isVirtualSymlink = true + inode.applyVirtualSymlinkAttrs(target) inode.mu.Unlock() } } @@ -524,7 +516,6 @@ func (parent *Inode) createVirtualSymlinksFromCache() []interface{} { parent.FullName(), len(parent.dir.symlinksCache.Symlinks), len(parent.dir.Children)) var notifications []interface{} - now := time.Now() for name, entry := range parent.dir.symlinksCache.Symlinks { // Skip if deleted if _, deleted := parent.dir.DeletedChildren[name]; deleted { @@ -539,14 +530,10 @@ func (parent *Inode) createVirtualSymlinksFromCache() []interface{} { // This handles the case where another mount created a symlink and we // have a stale inode without symlink attributes existingInode.mu.Lock() - if existingInode.userMetadata == nil { - existingInode.userMetadata = make(map[string][]byte) - } oldTarget := string(existingInode.userMetadata[fs.flags.SymlinkAttr]) wasSymlink := oldTarget != "" // Always update the symlink target from the cache (another mount may have changed it) - existingInode.userMetadata[fs.flags.SymlinkAttr] = []byte(entry.Target) - existingInode.isVirtualSymlink = true + existingInode.applyVirtualSymlinkAttrs(entry.Target) existingInode.mu.Unlock() s3Log.Debugf("createVirtualSymlinksFromCache: updated existing inode %v, oldTarget=%q newTarget=%q wasSymlink=%v", name, oldTarget, entry.Target, wasSymlink) @@ -564,20 +551,7 @@ func (parent *Inode) createVirtualSymlinksFromCache() []interface{} { // Create virtual symlink inode s3Log.Debugf("createVirtualSymlinksFromCache: creating new symlink inode %v -> %v", name, entry.Target) - inode := NewInode(fs, parent, name) - inode.userMetadata = make(map[string][]byte) - inode.userMetadata[fs.flags.SymlinkAttr] = []byte(entry.Target) - inode.isVirtualSymlink = true - inode.Attributes = InodeAttributes{ - Size: 0, - Mtime: time.Unix(entry.Mtime, 0), - Ctime: now, - Uid: fs.flags.Uid, - Gid: fs.flags.Gid, - Mode: fs.flags.FileMode, - } - fs.insertInode(parent, inode) - inode.SetCacheState(ST_CACHED) + parent.newVirtualSymlinkInode(name, entry.Target, time.Unix(entry.Mtime, 0)) } return notifications } @@ -1797,6 +1771,43 @@ func (parent *Inode) isSymlinkFromCache(name string) bool { return parent.dir.symlinksCache.HasSymlink(name) } +// newVirtualSymlinkInode creates a new virtual symlink inode from the symlinks cache, +// inserts it into the parent, and marks it as ST_CACHED. The mtime parameter allows +// using the mtime from the symlinks file entry; pass time.Time{} to use time.Now(). +// LOCKS_REQUIRED(parent.mu) +func (parent *Inode) newVirtualSymlinkInode(name, target string, mtime time.Time) *Inode { + fs := parent.fs + inode := NewInode(fs, parent, name) + inode.userMetadata = make(map[string][]byte) + inode.userMetadata[fs.flags.SymlinkAttr] = []byte(target) + inode.isVirtualSymlink = true + now := time.Now() + if mtime.IsZero() { + mtime = now + } + inode.Attributes = InodeAttributes{ + Size: 0, + Mtime: mtime, + Ctime: now, + Uid: fs.flags.Uid, + Gid: fs.flags.Gid, + Mode: fs.flags.FileMode, + } + fs.insertInode(parent, inode) + inode.SetCacheState(ST_CACHED) + return inode +} + +// applyVirtualSymlinkAttrs updates an existing inode with virtual symlink metadata. +// LOCKS_REQUIRED(inode.mu) +func (inode *Inode) applyVirtualSymlinkAttrs(target string) { + if inode.userMetadata == nil { + inode.userMetadata = make(map[string][]byte) + } + inode.userMetadata[inode.fs.flags.SymlinkAttr] = []byte(target) + inode.isVirtualSymlink = true +} + func (inode *Inode) ReadSymlink() (target string, err error) { inode.mu.Lock() defer inode.mu.Unlock() @@ -2384,22 +2395,7 @@ func (parent *Inode) LookUpCached(name string) (inode *Inode, err error) { s3Log.Warnf("Failed to load symlinks cache for %v: %v", parent.FullName(), err) } if target, found := parent.getSymlinkTargetFromCache(name); found { - // Create virtual symlink inode from cache - inode = NewInode(parent.fs, parent, name) - inode.userMetadata = make(map[string][]byte) - inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) - inode.isVirtualSymlink = true - now := time.Now() - inode.Attributes = InodeAttributes{ - Size: 0, - Mtime: now, - Ctime: now, - Uid: parent.fs.flags.Uid, - Gid: parent.fs.flags.Gid, - Mode: parent.fs.flags.FileMode, - } - parent.fs.insertInode(parent, inode) - inode.SetCacheState(ST_CACHED) + inode = parent.newVirtualSymlinkInode(name, target, time.Time{}) parent.mu.Unlock() return inode, nil } @@ -2457,30 +2453,10 @@ func (parent *Inode) LookUp(name string, doSlurp bool) (*Inode, error) { } if target, ok := parent.getSymlinkTargetFromCache(name); ok { if inode == nil { - // Create virtual symlink inode - inode = NewInode(parent.fs, parent, name) - inode.userMetadata = make(map[string][]byte) - inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) - inode.isVirtualSymlink = true - now := time.Now() - inode.Attributes = InodeAttributes{ - Size: 0, - Mtime: now, - Ctime: now, - Uid: parent.fs.flags.Uid, - Gid: parent.fs.flags.Gid, - Mode: parent.fs.flags.FileMode, - } - parent.fs.insertInode(parent, inode) - inode.SetCacheState(ST_CACHED) + inode = parent.newVirtualSymlinkInode(name, target, time.Time{}) } else { - // Ensure existing inode has symlink metadata inode.mu.Lock() - if inode.userMetadata == nil { - inode.userMetadata = make(map[string][]byte) - } - inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) - inode.isVirtualSymlink = true + inode.applyVirtualSymlinkAttrs(target) inode.SetAttrTime(time.Now()) inode.mu.Unlock() } @@ -2502,30 +2478,10 @@ func (parent *Inode) LookUp(name string, doSlurp bool) (*Inode, error) { // Check if inode already exists inode := parent.findChildUnlocked(name) if inode == nil { - // Create virtual symlink inode - inode = NewInode(parent.fs, parent, name) - inode.userMetadata = make(map[string][]byte) - inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) - inode.isVirtualSymlink = true - now := time.Now() - inode.Attributes = InodeAttributes{ - Size: 0, - Mtime: now, - Ctime: now, - Uid: parent.fs.flags.Uid, - Gid: parent.fs.flags.Gid, - Mode: parent.fs.flags.FileMode, - } - parent.fs.insertInode(parent, inode) - inode.SetCacheState(ST_CACHED) + inode = parent.newVirtualSymlinkInode(name, target, time.Time{}) } else { - // Ensure existing inode has symlink metadata inode.mu.Lock() - if inode.userMetadata == nil { - inode.userMetadata = make(map[string][]byte) - } - inode.userMetadata[parent.fs.flags.SymlinkAttr] = []byte(target) - inode.isVirtualSymlink = true + inode.applyVirtualSymlinkAttrs(target) inode.SetAttrTime(time.Now()) inode.mu.Unlock() } From ab67ceb95780d16fdde11b74c1fd66f547ea8f62 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 15:53:28 +0100 Subject: [PATCH 22/39] fix: use conditional write before deleting empty symlinks file Prevents a race where another mount adds a symlink between our empty check and the delete. The If-Match conditional write fails with 412 if the file was modified, letting the retry logic merge correctly. --- core/symlinks.go | 24 ++++++++++++++++++++++-- core/symlinks_test.go | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/core/symlinks.go b/core/symlinks.go index e23dfdac..79af8027 100644 --- a/core/symlinks.go +++ b/core/symlinks.go @@ -250,9 +250,29 @@ func SaveSymlinksFile(cloud StorageBackend, dirKey string, symlinksFileName stri return "", nil } - // If there are no symlinks, delete the file + // If there are no symlinks, delete the file using a conditional write first + // to detect concurrent modifications. S3 DeleteObject doesn't support If-Match, + // so we write the empty data with If-Match to ensure no one else has modified + // the file. If another mount added a symlink in between, the write fails with + // 412 and the retry logic in SaveSymlinksFileWithRetry handles it correctly. if data.IsEmpty() { - _, err := cloud.DeleteBlob(&DeleteBlobInput{Key: key}) + content, err := data.Serialize() + if err != nil { + return "", err + } + putInput := &PutBlobInput{ + Key: key, + Body: bytes.NewReader(content), + Size: PUInt64(uint64(len(content))), + IfMatch: &expectedETag, + } + _, err = cloud.PutBlob(putInput) + if err != nil { + return "", err + } + // Conditional write succeeded — safe to delete since we confirmed + // no concurrent modification + _, err = cloud.DeleteBlob(&DeleteBlobInput{Key: key}) if err != nil && !isNotExist(err) { return "", err } diff --git a/core/symlinks_test.go b/core/symlinks_test.go index 5ec5ccad..1adcefb0 100644 --- a/core/symlinks_test.go +++ b/core/symlinks_test.go @@ -390,6 +390,28 @@ func (s *SymlinksTest) TestSaveEmptySymlinksFileDeletesExisting(t *C) { t.Assert(exists, Equals, false) } +func (s *SymlinksTest) TestSaveEmptySymlinksFileRaceDetection(t *C) { + mock := newMockConditionalBackend() + + // Pre-create an existing file + mock.objects["testdir/.geesefs_symlinks"] = &mockStoredObject{ + data: []byte(`{"version":1,"symlinks":{"link1":{"target":"../target1"}}}`), + etag: "\"original\"", + } + + // Simulate another mount modifying the file before our delete + // by providing a stale ETag + emptyData := NewSymlinksFileData() + _, err := SaveSymlinksFile(mock, "testdir", ".geesefs_symlinks", emptyData, "\"stale\"") + t.Assert(err, NotNil) + t.Assert(isPreconditionFailed(err), Equals, true) + + // File should still exist with the other mount's data + obj, exists := mock.objects["testdir/.geesefs_symlinks"] + t.Assert(exists, Equals, true) + t.Assert(string(obj.data), Matches, ".*link1.*") +} + func (s *SymlinksTest) TestConcurrentSymlinkCreation(t *C) { mock := newMockConditionalBackend() From 621eeca8a32da1448b59003ce85b8e8e3d2bc6dd Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 16:02:23 +0100 Subject: [PATCH 23/39] perf: guard debug logging in LookUpCached with level check Avoids map lookups, mutex lock, and string formatting on every file lookup when debug logging is disabled. --- core/dir.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/dir.go b/core/dir.go index aea47f56..efed8c9e 100644 --- a/core/dir.go +++ b/core/dir.go @@ -26,6 +26,7 @@ import ( "time" "github.com/jacobsa/fuse/fuseops" + "github.com/sirupsen/logrus" "github.com/yandex-cloud/geesefs/core/cfg" ) @@ -2343,8 +2344,8 @@ func (parent *Inode) LookUpCached(name string) (inode *Inode, err error) { ok := false inode = parent.findChildUnlocked(name) - // Debug logging for symlinks troubleshooting - if parent.fs.flags.EnableSymlinksFile { + // Debug logging for symlinks troubleshooting (guarded to avoid overhead on every lookup) + if parent.fs.flags.EnableSymlinksFile && s3Log.IsLevelEnabled(logrus.DebugLevel) { childCount := len(parent.dir.Children) cacheValid := !expired(parent.dir.DirTime, parent.fs.flags.StatCacheTTL) hasSymlinksCache := parent.dir.symlinksCache != nil From c43f5542d1204dacecead13b4473362ef7a7cca6 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 16:08:14 +0100 Subject: [PATCH 24/39] cleanup: remove dead code (SymlinksFileCache, isSymlinkFromCache) Neither the struct nor the function were referenced anywhere. --- core/dir.go | 9 --------- core/symlinks.go | 9 --------- 2 files changed, 18 deletions(-) diff --git a/core/dir.go b/core/dir.go index efed8c9e..973625a0 100644 --- a/core/dir.go +++ b/core/dir.go @@ -1763,15 +1763,6 @@ func (parent *Inode) getSymlinkTargetFromCache(name string) (string, bool) { return parent.dir.symlinksCache.GetSymlink(name) } -// isSymlinkFromCache checks if a file is a symlink according to the symlinks file -// LOCKS_REQUIRED(parent.mu) -func (parent *Inode) isSymlinkFromCache(name string) bool { - if parent.dir.symlinksCache == nil { - return false - } - return parent.dir.symlinksCache.HasSymlink(name) -} - // newVirtualSymlinkInode creates a new virtual symlink inode from the symlinks cache, // inserts it into the parent, and marks it as ST_CACHED. The mtime parameter allows // using the mtime from the symlinks file entry; pass time.Time{} to use time.Now(). diff --git a/core/symlinks.go b/core/symlinks.go index 79af8027..7e8313c3 100644 --- a/core/symlinks.go +++ b/core/symlinks.go @@ -20,7 +20,6 @@ import ( "fmt" "io" "strings" - "sync" "syscall" "time" @@ -39,14 +38,6 @@ type SymlinksFileData struct { Symlinks map[string]SymlinkEntry `json:"symlinks"` } -// SymlinksFileCache caches the .symlinks file data for a directory -type SymlinksFileCache struct { - mu sync.RWMutex - data *SymlinksFileData - etag string - loadTime time.Time -} - // NewSymlinksFileData creates a new empty symlinks file data structure func NewSymlinksFileData() *SymlinksFileData { return &SymlinksFileData{ From 3965b0c5ac99de937a11dd45337b8a5e25f1b073 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 17:39:41 +0100 Subject: [PATCH 25/39] fix: add jitter to retry backoff in SaveSymlinksFileWithRetry Randomizes sleep duration in [backoff/2, backoff) to avoid thundering herd when multiple mounts contend on the same symlinks file. --- core/symlinks.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/symlinks.go b/core/symlinks.go index 7e8313c3..41d3b95f 100644 --- a/core/symlinks.go +++ b/core/symlinks.go @@ -19,6 +19,7 @@ import ( "encoding/json" "fmt" "io" + "math/rand" "strings" "syscall" "time" @@ -378,8 +379,11 @@ func SaveSymlinksFileWithRetry( return "", fmt.Errorf("symlinks file conflict: max retries (%d) exceeded: %w", maxRetries, err) } - // Wait with exponential backoff before retrying - time.Sleep(backoff) + // Wait with exponential backoff + jitter before retrying + // Full jitter: sleep for a random duration in [backoff/2, backoff) + // to avoid thundering herd when multiple mounts contend + jitter := backoff/2 + time.Duration(rand.Int63n(int64(backoff/2))) + time.Sleep(jitter) backoff = time.Duration(float64(backoff) * backoffFactor) if backoff > maxBackoff { backoff = maxBackoff From ed4ee63e8490703c31358deca2c38ee7383cc583 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 17:56:55 +0100 Subject: [PATCH 26/39] perf: use compact JSON for symlinks file serialization Replace json.MarshalIndent with json.Marshal to reduce S3 storage and bandwidth for .geesefs_symlinks files. --- core/symlinks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/symlinks.go b/core/symlinks.go index 41d3b95f..d321be21 100644 --- a/core/symlinks.go +++ b/core/symlinks.go @@ -67,7 +67,7 @@ func ParseSymlinksFile(data []byte) (*SymlinksFileData, error) { // Serialize converts the symlinks data to JSON bytes func (s *SymlinksFileData) Serialize() ([]byte, error) { - return json.MarshalIndent(s, "", " ") + return json.Marshal(s) } // AddSymlink adds or updates a symlink entry From 83e7ec28ef56ebdae8be45ffea37dff51acb1d9c Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 18:06:57 +0100 Subject: [PATCH 27/39] refactor: replace IsS3Compatible() with SupportsConditionalWrites capability Use an explicit capability flag instead of hardcoded backend name matching to determine conditional write support. --- core/backend.go | 9 +++------ core/backend_s3.go | 5 +++-- core/goofys.go | 6 +++--- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/core/backend.go b/core/backend.go index cf398513..f76a4604 100644 --- a/core/backend.go +++ b/core/backend.go @@ -30,12 +30,9 @@ type Capabilities struct { // indicates that the blob store has native support for directories DirBlob bool Name string -} - -// IsS3Compatible returns true if the backend is S3-compatible (supports conditional writes). -// This includes AWS S3, MinIO, GCS (via S3 API), and other S3-compatible storage services. -func (c *Capabilities) IsS3Compatible() bool { - return c.Name == "s3" || c.Name == "gcs" + // SupportsConditionalWrites indicates the backend supports If-Match/If-None-Match + // conditional headers on PutObject (used for optimistic locking of .geesefs_symlinks) + SupportsConditionalWrites bool } type HeadBlobInput struct { diff --git a/core/backend_s3.go b/core/backend_s3.go index 9b3b3ed8..0784d110 100644 --- a/core/backend_s3.go +++ b/core/backend_s3.go @@ -80,8 +80,9 @@ func NewS3(bucket string, flags *cfg.FlagStorage, config *cfg.S3Config) (*S3Back flags: flags, config: config, cap: Capabilities{ - Name: "s3", - MaxMultipartSize: 5 * 1024 * 1024 * 1024, + Name: "s3", + MaxMultipartSize: 5 * 1024 * 1024 * 1024, + SupportsConditionalWrites: true, }, } diff --git a/core/goofys.go b/core/goofys.go index 2bc2ea28..ff2821a7 100644 --- a/core/goofys.go +++ b/core/goofys.go @@ -323,9 +323,9 @@ func newGoofys(ctx context.Context, bucket string, flags *cfg.FlagStorage, return nil, fmt.Errorf("Unable to setup backend: %v", err) } - // Validate that symlinks file feature is only used with S3-compatible backends - if flags.EnableSymlinksFile && !cloud.Capabilities().IsS3Compatible() { - return nil, fmt.Errorf("--enable-symlinks-file is only supported with S3-compatible backends (s3, gcs, minio). Current backend: %v", cloud.Capabilities().Name) + // Validate that symlinks file feature is only used with backends that support conditional writes + if flags.EnableSymlinksFile && !cloud.Capabilities().SupportsConditionalWrites { + return nil, fmt.Errorf("--enable-symlinks-file requires a backend that supports conditional writes (If-Match/If-None-Match). Current backend: %v", cloud.Capabilities().Name) } randomObjectName := prefix + (RandStringBytesMaskImprSrc(32)) From 0071597b78d7628b12b726146a5eed2ab7e20cba Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 18:10:33 +0100 Subject: [PATCH 28/39] test: add cache corruption recovery and edge case tests Add tests for corrupted symlinks file loading, recovery after corruption, missing symlinks field, and forward version compatibility. --- core/symlinks_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/core/symlinks_test.go b/core/symlinks_test.go index 1adcefb0..69d5571b 100644 --- a/core/symlinks_test.go +++ b/core/symlinks_test.go @@ -100,6 +100,66 @@ func (s *SymlinksTest) TestParseInvalidJSON(t *C) { t.Assert(err, NotNil) } +func (s *SymlinksTest) TestLoadCorruptedSymlinksFile(t *C) { + mock := newMockConditionalBackend() + + // Store corrupted JSON in S3 + mock.objects["testdir/.geesefs_symlinks"] = &mockStoredObject{ + data: []byte("<<>>"), + etag: "\"corrupt\"", + } + + // LoadSymlinksFile should return an error for corrupted data + _, _, err := LoadSymlinksFile(mock, "testdir", ".geesefs_symlinks") + t.Assert(err, NotNil) +} + +func (s *SymlinksTest) TestRecoveryAfterCorruption(t *C) { + mock := newMockConditionalBackend() + + // Store corrupted JSON in S3 + mock.objects["testdir/.geesefs_symlinks"] = &mockStoredObject{ + data: []byte("{invalid json"), + etag: "\"corrupt\"", + } + + // Load fails due to corruption + _, _, err := LoadSymlinksFile(mock, "testdir", ".geesefs_symlinks") + t.Assert(err, NotNil) + + // Overwrite with valid data (using the known ETag) + data := NewSymlinksFileData() + data.AddSymlink("link1", "../target1") + newETag, err := SaveSymlinksFile(mock, "testdir", ".geesefs_symlinks", data, "\"corrupt\"") + t.Assert(err, IsNil) + t.Assert(newETag != "", Equals, true) + + // Load should now succeed + loaded, _, err := LoadSymlinksFile(mock, "testdir", ".geesefs_symlinks") + t.Assert(err, IsNil) + target, ok := loaded.GetSymlink("link1") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "../target1") +} + +func (s *SymlinksTest) TestParseMissingSymlinksField(t *C) { + // Valid JSON but missing "symlinks" field + data, err := ParseSymlinksFile([]byte(`{"version":1}`)) + t.Assert(err, IsNil) + t.Assert(data.IsEmpty(), Equals, true) + t.Assert(data.Symlinks, NotNil) // should be initialized, not nil +} + +func (s *SymlinksTest) TestParseUnknownVersion(t *C) { + // Higher version number should still parse (forward compatible) + data, err := ParseSymlinksFile([]byte(`{"version":99,"symlinks":{"link1":{"target":"t1"}}}`)) + t.Assert(err, IsNil) + t.Assert(data.Version, Equals, 99) + target, ok := data.GetSymlink("link1") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "t1") +} + func (s *SymlinksTest) TestDeepCopy(t *C) { original := NewSymlinksFileData() original.AddSymlink("link1", "../target1") From 97ea609bc4ca4d2818e3be9edbfc5cee4c8ee537 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 18:32:28 +0100 Subject: [PATCH 29/39] fix: clip copy ranges to source object size in copyUnmodifiedParts When a file grows beyond its original S3 size, copyUnmodifiedParts was clipping part ranges to Attributes.Size (the new local size) instead of knownSize (the source S3 object size). This caused S3 to return 400 errors for copy ranges exceeding the source object bounds. --- core/file.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/core/file.go b/core/file.go index a594c308..df164e21 100644 --- a/core/file.go +++ b/core/file.go @@ -1548,9 +1548,16 @@ func (inode *Inode) copyUnmodifiedParts(numParts uint64) (err error) { var startOffset, endOffset uint64 for i := uint64(0); i < numParts; i++ { partOffset, partSize := inode.fs.partRange(i) + // Skip parts that start at or beyond the source object size — they + // don't exist in S3 and can't be server-side copied + if partOffset >= inode.knownSize { + break + } partEnd := partOffset + partSize - if partEnd > inode.Attributes.Size { - partEnd = inode.Attributes.Size + // Clip to the source object size, not Attributes.Size, because the + // file may have grown locally beyond the S3 object + if partEnd > inode.knownSize { + partEnd = inode.knownSize } if inode.mpu.Parts[i] == nil { if endPart == 0 { From d30d74aeca6761a4113e2b3028eb8005cd4dc5a0 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Tue, 17 Feb 2026 19:09:13 +0100 Subject: [PATCH 30/39] feat: batch symlink changes with configurable delay before S3 PUT Each symlink create/delete previously triggered an immediate S3 PUT to .geesefs_symlinks. Creating 10 symlinks = 10 PUTs. This batches them into a single PUT per directory using a configurable delay timer (--symlinks-batch-delay, default 100ms). The in-memory cache is updated eagerly so local readers see changes immediately, but the S3 PUT is deferred. Uses per-directory time.AfterFunc timers matching the existing ScheduleRetryFlush pattern. Flush is triggered by: timer expiry, sync/fsync, or unmount. On conflict, the merge function replays all batched changes. On failure, changes are re-queued for retry. --- core/cfg/config.go | 3 +- core/cfg/flags.go | 8 + core/dir.go | 177 ++++++-- core/goofys.go | 33 ++ core/symlinks_test.go | 389 ++++++++++++++++++ .../tests/symlinks_file/test_symlinks_file.sh | 108 +++++ 6 files changed, 684 insertions(+), 34 deletions(-) diff --git a/core/cfg/config.go b/core/cfg/config.go index d858bae1..48131df9 100644 --- a/core/cfg/config.go +++ b/core/cfg/config.go @@ -97,7 +97,8 @@ type FlagStorage struct { SymlinkAttr string SymlinksFile string // Name of the hidden file storing symlinks metadata EnableSymlinksFile bool // Use .symlinks file instead of object metadata for symlinks - HideSymlinksFile bool // Hide the .symlinks file from directory listings + HideSymlinksFile bool // Hide the .symlinks file from directory listings + SymlinksBatchDelay time.Duration // Delay before flushing batched symlink changes (0 = disable) RefreshAttr string RefreshFilename string FlushFilename string diff --git a/core/cfg/flags.go b/core/cfg/flags.go index ddcb3ccc..33ce2f2f 100644 --- a/core/cfg/flags.go +++ b/core/cfg/flags.go @@ -583,6 +583,12 @@ MISC OPTIONS: Usage: "Hide the .symlinks file from directory listings. Set to false to show it. (default: true)", }, + cli.DurationFlag{ + Name: "symlinks-batch-delay", + Value: 100 * time.Millisecond, + Usage: "Delay before flushing batched symlinks changes to S3. 0 to disable batching.", + }, + cli.StringFlag{ Name: "refresh-attr", Value: ".invalidate", @@ -888,6 +894,7 @@ func PopulateFlags(c *cli.Context) (ret *FlagStorage) { SymlinksFile: c.String("symlinks-file"), EnableSymlinksFile: c.Bool("enable-symlinks-file"), HideSymlinksFile: c.Bool("hide-symlinks-file"), + SymlinksBatchDelay: c.Duration("symlinks-batch-delay"), RefreshAttr: c.String("refresh-attr"), CachePath: c.String("cache"), MaxDiskCacheFD: int64(c.Int("max-disk-cache-fd")), @@ -1093,6 +1100,7 @@ func DefaultFlags() *FlagStorage { MtimeAttr: "mtime", SymlinkAttr: "--symlink-target", SymlinksFile: ".geesefs_symlinks", + SymlinksBatchDelay: 100 * time.Millisecond, RefreshAttr: ".invalidate", StatCacheTTL: 30 * time.Second, HTTPTimeout: 30 * time.Second, diff --git a/core/dir.go b/core/dir.go index 973625a0..ae6b52bc 100644 --- a/core/dir.go +++ b/core/dir.go @@ -37,6 +37,13 @@ type SlurpGap struct { loadTime time.Time } +// PendingSymlinkChange represents a batched symlink create or delete operation. +type PendingSymlinkChange struct { + Name string + Target string // empty for removes + Remove bool +} + type DirInodeData struct { cloud StorageBackend mountPrefix string @@ -66,6 +73,11 @@ type DirInodeData struct { symlinksCache *SymlinksFileData symlinksCacheETag string symlinksCacheTime time.Time + + // Symlinks batching (used when SymlinksBatchDelay > 0) + pendingSymlinkChanges []PendingSymlinkChange // GUARDED_BY(parent.mu) + symlinkFlushTimer *time.Timer // GUARDED_BY(parent.mu) + symlinkFlushETag string // ETag at time first change was queued } // Returns the position of first char < '/' in `inp` after prefixLen + any continued '/' characters. @@ -1103,6 +1115,11 @@ func (inode *Inode) ResetForUnmount() { } inode.mu.Lock() + // Stop any pending symlinks flush timer + if inode.dir.symlinkFlushTimer != nil { + inode.dir.symlinkFlushTimer.Stop() + inode.dir.symlinkFlushTimer = nil + } // First reset the cloud info for this directory. After that, any read and // write operations under this directory will not know about this cloud. inode.dir.cloud = nil @@ -1635,52 +1652,99 @@ func (parent *Inode) CreateSymlink( } // updateSymlinksFile updates the .symlinks file in this directory. +// When SymlinksBatchDelay > 0, the in-memory cache is updated eagerly +// and the S3 PUT is deferred. When SymlinksBatchDelay == 0, the S3 PUT +// happens immediately (preserving the original behavior). // LOCKS_REQUIRED(parent.mu) // Note: temporarily releases parent.mu during network I/O. func (parent *Inode) updateSymlinksFile(name string, target string, remove bool) error { - cloud, dirKey := parent.cloud() + cloud, _ := parent.cloud() if cloud == nil { return syscall.ESTALE } - // Remove trailing slash from dirKey for consistency - dirKey = strings.TrimSuffix(dirKey, "/") - - symlinksFileName := parent.fs.flags.SymlinksFile - - // Load or use cached symlinks data — always work on a deep copy - // so the in-memory cache is not corrupted if the save fails. - var data *SymlinksFileData - var etag string - var err error - - if parent.dir.symlinksCache != nil { - data = parent.dir.symlinksCache.DeepCopy() - etag = parent.dir.symlinksCacheETag - } else { - // Need to load from cloud — release lock during I/O - parent.mu.Unlock() - data, etag, err = LoadSymlinksFile(cloud, dirKey, symlinksFileName) - parent.mu.Lock() - if err != nil { + // Ensure the cache is loaded before modifying + if parent.dir.symlinksCache == nil { + if err := parent.loadSymlinksCache(); err != nil { return err } + // loadSymlinksCache may have loaded it; if still nil, initialize empty + if parent.dir.symlinksCache == nil { + parent.dir.symlinksCache = NewSymlinksFileData() + } } - // Update the copy (original cache is untouched) + // Update in-memory cache eagerly — all local readers see changes immediately if remove { - data.RemoveSymlink(name) + parent.dir.symlinksCache.RemoveSymlink(name) } else { - data.AddSymlink(name, target) + parent.dir.symlinksCache.AddSymlink(name, target) } - // Define merge function for conflict resolution + if parent.fs.flags.SymlinksBatchDelay == 0 { + // Batching disabled: flush immediately + parent.dir.pendingSymlinkChanges = append(parent.dir.pendingSymlinkChanges, + PendingSymlinkChange{Name: name, Target: target, Remove: remove}) + if parent.dir.symlinkFlushETag == "" && parent.dir.symlinksCacheETag != "" { + parent.dir.symlinkFlushETag = parent.dir.symlinksCacheETag + } + return parent.flushSymlinksChanges() + } + + // Batching enabled: queue the change and schedule a deferred flush + if len(parent.dir.pendingSymlinkChanges) == 0 { + // Capture ETag at time first change is queued + parent.dir.symlinkFlushETag = parent.dir.symlinksCacheETag + } + parent.dir.pendingSymlinkChanges = append(parent.dir.pendingSymlinkChanges, + PendingSymlinkChange{Name: name, Target: target, Remove: remove}) + parent.scheduleSymlinksFlush() + + return nil +} + +// flushSymlinksChanges flushes all pending symlink changes to S3. +// It deep-copies the current cache, replays all pending changes onto a fresh +// copy for the merge function, and uses SaveSymlinksFileWithRetry. +// On failure, changes are prepended back to the pending queue. +// LOCKS_REQUIRED(parent.mu) +// Note: temporarily releases parent.mu during network I/O. +func (parent *Inode) flushSymlinksChanges() error { + if len(parent.dir.pendingSymlinkChanges) == 0 { + return nil + } + + cloud, dirKey := parent.cloud() + if cloud == nil { + return syscall.ESTALE + } + dirKey = strings.TrimSuffix(dirKey, "/") + symlinksFileName := parent.fs.flags.SymlinksFile + + // Snapshot and clear pending changes + changes := parent.dir.pendingSymlinkChanges + parent.dir.pendingSymlinkChanges = nil + etag := parent.dir.symlinkFlushETag + parent.dir.symlinkFlushETag = "" + + // Stop timer if running + if parent.dir.symlinkFlushTimer != nil { + parent.dir.symlinkFlushTimer.Stop() + parent.dir.symlinkFlushTimer = nil + } + + // Deep-copy the current in-memory cache for the S3 PUT payload. + // The in-memory cache already has all changes applied eagerly. + data := parent.dir.symlinksCache.DeepCopy() + + // Build merge function that replays ALL batched changes on conflict mergeFn := func(currentData *SymlinksFileData) (*SymlinksFileData, error) { - // Re-apply our change to the current cloud state - if remove { - currentData.RemoveSymlink(name) - } else { - currentData.AddSymlink(name, target) + for _, ch := range changes { + if ch.Remove { + currentData.RemoveSymlink(ch.Name) + } else { + currentData.AddSymlink(ch.Name, ch.Target) + } } return currentData, nil } @@ -1690,18 +1754,55 @@ func (parent *Inode) updateSymlinksFile(name string, target string, remove bool) parent.mu.Unlock() newETag, err := SaveSymlinksFileWithRetry(cloud, dirKey, symlinksFileName, data, etag, mergeFn, maxRetries) parent.mu.Lock() + if err != nil { + // Re-queue failed changes at the front so they are retried + parent.dir.pendingSymlinkChanges = append(changes, parent.dir.pendingSymlinkChanges...) + if parent.dir.symlinkFlushETag == "" { + parent.dir.symlinkFlushETag = etag + } return err } - // Only update cache after successful save - parent.dir.symlinksCache = data + // Update cache metadata after successful save parent.dir.symlinksCacheETag = newETag parent.dir.symlinksCacheTime = time.Now() - return nil } +// scheduleSymlinksFlush schedules a deferred flush of pending symlink changes. +// No-op if a timer is already running. +// LOCKS_REQUIRED(parent.mu) +func (parent *Inode) scheduleSymlinksFlush() { + if parent.dir.symlinkFlushTimer != nil { + return // timer already running + } + delay := parent.fs.flags.SymlinksBatchDelay + parent.dir.symlinkFlushTimer = time.AfterFunc(delay, func() { + parent.mu.Lock() + parent.dir.symlinkFlushTimer = nil + err := parent.flushSymlinksChanges() + if err != nil { + s3Log.Warnf("flushSymlinksChanges failed for %v: %v, will retry", parent.FullName(), err) + // Re-schedule on error + parent.scheduleSymlinksFlush() + } + parent.mu.Unlock() + }) +} + +// FlushPendingSymlinks flushes any pending batched symlink changes to S3. +// Public entry point used by SyncTree and Shutdown. +// ACQUIRES_LOCK(parent.mu) +func (parent *Inode) FlushPendingSymlinks() error { + parent.mu.Lock() + defer parent.mu.Unlock() + if parent.dir == nil || len(parent.dir.pendingSymlinkChanges) == 0 { + return nil + } + return parent.flushSymlinksChanges() +} + // loadSymlinksCache loads the symlinks file cache for this directory if needed. // Skips the network call if the cache was loaded recently (within StatCacheTTL). // LOCKS_REQUIRED(parent.mu) @@ -1751,6 +1852,16 @@ func (parent *Inode) loadSymlinksCache() error { parent.dir.symlinksCacheETag = etag parent.dir.symlinksCacheTime = time.Now() + // Re-apply any pending symlink changes to the freshly loaded data + // so that local readers see consistent state + for _, ch := range parent.dir.pendingSymlinkChanges { + if ch.Remove { + parent.dir.symlinksCache.RemoveSymlink(ch.Name) + } else { + parent.dir.symlinksCache.AddSymlink(ch.Name, ch.Target) + } + } + return nil } diff --git a/core/goofys.go b/core/goofys.go index ff2821a7..b7fe848a 100644 --- a/core/goofys.go +++ b/core/goofys.go @@ -391,6 +391,7 @@ func newGoofys(ctx context.Context, bucket string, flags *cfg.FlagStorage, } func (fs *Goofys) Shutdown() { + fs.flushAllPendingSymlinks() atomic.StoreInt32(&fs.shutdown, 1) close(fs.shutdownCh) fs.WakeupFlusher() @@ -399,6 +400,27 @@ func (fs *Goofys) Shutdown() { } } +// flushAllPendingSymlinks walks all directory inodes and flushes any +// pending batched symlink changes to S3. +func (fs *Goofys) flushAllPendingSymlinks() { + if !fs.flags.EnableSymlinksFile { + return + } + fs.mu.RLock() + inodes := make([]*Inode, 0) + for _, inode := range fs.inodes { + if inode.isDir() { + inodes = append(inodes, inode) + } + } + fs.mu.RUnlock() + for _, inode := range inodes { + if err := inode.FlushPendingSymlinks(); err != nil { + s3Log.Warnf("flushAllPendingSymlinks: failed for %v: %v", inode.FullName(), err) + } + } +} + // from https://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-golang func RandStringBytesMaskImprSrc(n int) string { const letterBytes = "abcdefghijklmnopqrstuvwxyz0123456789" @@ -1170,6 +1192,17 @@ func (fs *Goofys) SyncTree(parent *Inode) (err error) { fs.mu.RUnlock() if inode != nil { inode.SyncFile() + if fs.flags.EnableSymlinksFile && inode.isDir() { + if err := inode.FlushPendingSymlinks(); err != nil { + s3Log.Warnf("SyncTree: flush symlinks failed for %v: %v", inode.FullName(), err) + } + } + } + } + // Also flush the parent directory itself (isParentOf does not include self) + if parent != nil && fs.flags.EnableSymlinksFile && parent.isDir() { + if err := parent.FlushPendingSymlinks(); err != nil { + s3Log.Warnf("SyncTree: flush symlinks failed for %v: %v", parent.FullName(), err) } } return diff --git a/core/symlinks_test.go b/core/symlinks_test.go index 69d5571b..725dc578 100644 --- a/core/symlinks_test.go +++ b/core/symlinks_test.go @@ -16,9 +16,13 @@ package core import ( "fmt" + "sync" "syscall" + "time" "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/jacobsa/fuse/fuseops" + "github.com/yandex-cloud/geesefs/core/cfg" . "gopkg.in/check.v1" ) @@ -908,3 +912,388 @@ func (s *SymlinksTest) TestIsNotModifiedWithNilAndUnrelated(t *C) { t.Assert(isNotModified(fmt.Errorf("network timeout")), Equals, false) } +// ============================================================================ +// Helper: creates a minimal Goofys + directory inode for batching tests +// ============================================================================ + +func newTestDirInode(mock StorageBackend, batchDelay time.Duration) (*Goofys, *Inode) { + flags := cfg.DefaultFlags() + flags.EnableSymlinksFile = true + flags.SymlinksBatchDelay = batchDelay + flags.StatCacheTTL = 30 * time.Second + + fs := &Goofys{ + flags: flags, + inodes: make(map[fuseops.InodeID]*Inode), + nextInodeID: fuseops.RootInodeID + 1, + inflightChanges: make(map[string]int), + inflightListings: make(map[int]map[string]bool), + } + fs.flusherCond = sync.NewCond(&fs.flusherMu) + + root := &Inode{ + Name: "", + fs: fs, + Id: fuseops.RootInodeID, + dir: &DirInodeData{ + cloud: mock, + mountPrefix: "", + lastOpenDirIdx: -1, + }, + } + fs.inodes[fuseops.RootInodeID] = root + return fs, root +} + +// ============================================================================ +// Tests for symlinks batching +// ============================================================================ + +func (s *SymlinksTest) TestBatchingQueuesChangesAndUpdatesCache(t *C) { + mock := newMockConditionalBackend() + _, parent := newTestDirInode(mock, 1*time.Hour) // long delay so timer doesn't fire + + parent.mu.Lock() + + // Create first symlink + err := parent.updateSymlinksFile("link1", "../target1", false) + t.Assert(err, IsNil) + + // Cache should be updated eagerly + t.Assert(parent.dir.symlinksCache.HasSymlink("link1"), Equals, true) + + // Pending queue should have one entry + t.Assert(len(parent.dir.pendingSymlinkChanges), Equals, 1) + t.Assert(parent.dir.pendingSymlinkChanges[0].Name, Equals, "link1") + + // Create second symlink + err = parent.updateSymlinksFile("link2", "../target2", false) + t.Assert(err, IsNil) + + // Cache should have both + t.Assert(parent.dir.symlinksCache.HasSymlink("link1"), Equals, true) + t.Assert(parent.dir.symlinksCache.HasSymlink("link2"), Equals, true) + + // Pending queue should have two entries + t.Assert(len(parent.dir.pendingSymlinkChanges), Equals, 2) + + // No S3 PUT should have happened yet + _, exists := mock.objects[".geesefs_symlinks"] + t.Assert(exists, Equals, false) + + parent.mu.Unlock() +} + +func (s *SymlinksTest) TestBatchFlushCoalescesChanges(t *C) { + mock := newMockConditionalBackend() + _, parent := newTestDirInode(mock, 1*time.Hour) + + parent.mu.Lock() + + // Create 10 symlinks + for i := 0; i < 10; i++ { + name := fmt.Sprintf("link%d", i) + target := fmt.Sprintf("../target%d", i) + err := parent.updateSymlinksFile(name, target, false) + t.Assert(err, IsNil) + } + + t.Assert(len(parent.dir.pendingSymlinkChanges), Equals, 10) + + // Count PutBlob calls + putCount := 0 + mock.onPutBlob = func(param *PutBlobInput) { + putCount++ + } + + // Flush should produce a single S3 PUT + err := parent.flushSymlinksChanges() + t.Assert(err, IsNil) + t.Assert(putCount, Equals, 1) + + // Pending queue should be empty + t.Assert(len(parent.dir.pendingSymlinkChanges), Equals, 0) + + // S3 should have all 10 symlinks + obj := mock.objects[".geesefs_symlinks"] + t.Assert(obj, NotNil) + parsed, err := ParseSymlinksFile(obj.data) + t.Assert(err, IsNil) + t.Assert(len(parsed.Symlinks), Equals, 10) + + parent.mu.Unlock() +} + +func (s *SymlinksTest) TestBatchMergeReplaysAllChangesOnConflict(t *C) { + mock := newMockConditionalBackend() + _, parent := newTestDirInode(mock, 1*time.Hour) + + // Pre-create a symlinks file in S3 (simulating another mount) + otherData := NewSymlinksFileData() + otherData.AddSymlink("other-link", "../other-target") + etag, err := SaveSymlinksFile(mock, "", ".geesefs_symlinks", otherData, "") + t.Assert(err, IsNil) + + parent.mu.Lock() + + // Load cache (gets the existing file) + err = parent.loadSymlinksCache() + t.Assert(err, IsNil) + t.Assert(parent.dir.symlinksCache.HasSymlink("other-link"), Equals, true) + + // Queue two changes + err = parent.updateSymlinksFile("link1", "../target1", false) + t.Assert(err, IsNil) + err = parent.updateSymlinksFile("link2", "../target2", false) + t.Assert(err, IsNil) + + // Simulate a concurrent modification: another mount changed the file + otherData2 := NewSymlinksFileData() + otherData2.AddSymlink("other-link", "../other-target") + otherData2.AddSymlink("concurrent-link", "../concurrent-target") + // Overwrite directly (bypassing conditional checks for test setup) + mock.mu.Lock() + serialized, _ := otherData2.Serialize() + mock.objects[".geesefs_symlinks"] = &mockStoredObject{ + data: serialized, + etag: "\"concurrent-etag\"", + } + mock.mu.Unlock() + + // Flush with stale ETag - should trigger merge + err = parent.flushSymlinksChanges() + t.Assert(err, IsNil) + + // Result should have all 4 symlinks (other-link, concurrent-link, link1, link2) + obj := mock.objects[".geesefs_symlinks"] + parsed, err := ParseSymlinksFile(obj.data) + t.Assert(err, IsNil) + t.Assert(parsed.HasSymlink("other-link"), Equals, true) + t.Assert(parsed.HasSymlink("concurrent-link"), Equals, true) + t.Assert(parsed.HasSymlink("link1"), Equals, true) + t.Assert(parsed.HasSymlink("link2"), Equals, true) + + _ = etag + parent.mu.Unlock() +} + +func (s *SymlinksTest) TestBatchOrderPreservation(t *C) { + mock := newMockConditionalBackend() + _, parent := newTestDirInode(mock, 1*time.Hour) + + parent.mu.Lock() + + // ln -s a foo && rm foo && ln -s b foo + err := parent.updateSymlinksFile("foo", "a", false) + t.Assert(err, IsNil) + err = parent.updateSymlinksFile("foo", "", true) + t.Assert(err, IsNil) + err = parent.updateSymlinksFile("foo", "b", false) + t.Assert(err, IsNil) + + // In-memory cache should show foo -> b + target, ok := parent.dir.symlinksCache.GetSymlink("foo") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "b") + + // Flush + err = parent.flushSymlinksChanges() + t.Assert(err, IsNil) + + // S3 should have foo -> b + obj := mock.objects[".geesefs_symlinks"] + parsed, err := ParseSymlinksFile(obj.data) + t.Assert(err, IsNil) + target, ok = parsed.GetSymlink("foo") + t.Assert(ok, Equals, true) + t.Assert(target, Equals, "b") + + parent.mu.Unlock() +} + +func (s *SymlinksTest) TestNoBatchingFlushesImmediately(t *C) { + mock := newMockConditionalBackend() + _, parent := newTestDirInode(mock, 0) // batching disabled + + parent.mu.Lock() + + // Create a symlink - should flush immediately + err := parent.updateSymlinksFile("link1", "../target1", false) + t.Assert(err, IsNil) + + // Pending queue should be empty (already flushed) + t.Assert(len(parent.dir.pendingSymlinkChanges), Equals, 0) + + // S3 should have the symlink + obj := mock.objects[".geesefs_symlinks"] + t.Assert(obj, NotNil) + parsed, err := ParseSymlinksFile(obj.data) + t.Assert(err, IsNil) + t.Assert(parsed.HasSymlink("link1"), Equals, true) + + parent.mu.Unlock() +} + +func (s *SymlinksTest) TestCacheReloadPreservesPendingChanges(t *C) { + mock := newMockConditionalBackend() + _, parent := newTestDirInode(mock, 1*time.Hour) + + parent.mu.Lock() + + // Create initial symlinks and flush them + err := parent.updateSymlinksFile("existing", "../existing-target", false) + t.Assert(err, IsNil) + err = parent.flushSymlinksChanges() + t.Assert(err, IsNil) + + // Queue a new change (not yet flushed) + err = parent.updateSymlinksFile("pending", "../pending-target", false) + t.Assert(err, IsNil) + t.Assert(len(parent.dir.pendingSymlinkChanges), Equals, 1) + + // Force cache to expire by setting time in the past + parent.dir.symlinksCacheTime = time.Time{} + + // Reload the cache from S3 + err = parent.loadSymlinksCache() + t.Assert(err, IsNil) + + // The pending change should still be visible in the cache + t.Assert(parent.dir.symlinksCache.HasSymlink("pending"), Equals, true) + t.Assert(parent.dir.symlinksCache.HasSymlink("existing"), Equals, true) + + parent.mu.Unlock() +} + +func (s *SymlinksTest) TestFlushErrorRequeuesChanges(t *C) { + mock := newMockConditionalBackend() + _, parent := newTestDirInode(mock, 1*time.Hour) + + // Pre-create a symlinks file with a different ETag to cause conflict + otherData := NewSymlinksFileData() + etag, err := SaveSymlinksFile(mock, "", ".geesefs_symlinks", otherData, "") + t.Assert(err, IsNil) + + parent.mu.Lock() + + // Load cache + err = parent.loadSymlinksCache() + t.Assert(err, IsNil) + + // Queue a change + err = parent.updateSymlinksFile("link1", "../target1", false) + t.Assert(err, IsNil) + + // Replace the backend with one that always fails + failMock := &alwaysConflictBackend{mock} + parent.dir.cloud = failMock + + // Flush should fail + err = parent.flushSymlinksChanges() + t.Assert(err, NotNil) + + // Changes should be re-queued + t.Assert(len(parent.dir.pendingSymlinkChanges), Equals, 1) + t.Assert(parent.dir.pendingSymlinkChanges[0].Name, Equals, "link1") + + // In-memory cache should still have the change + t.Assert(parent.dir.symlinksCache.HasSymlink("link1"), Equals, true) + + _ = etag + parent.mu.Unlock() +} + +func (s *SymlinksTest) TestFlushPendingSymlinksPublicAPI(t *C) { + mock := newMockConditionalBackend() + _, parent := newTestDirInode(mock, 1*time.Hour) + + parent.mu.Lock() + + // Queue changes + err := parent.updateSymlinksFile("link1", "../target1", false) + t.Assert(err, IsNil) + err = parent.updateSymlinksFile("link2", "../target2", false) + t.Assert(err, IsNil) + + parent.mu.Unlock() + + // Use public API (acquires lock internally) + err = parent.FlushPendingSymlinks() + t.Assert(err, IsNil) + + // Verify everything was flushed + parent.mu.Lock() + t.Assert(len(parent.dir.pendingSymlinkChanges), Equals, 0) + parent.mu.Unlock() + + obj := mock.objects[".geesefs_symlinks"] + t.Assert(obj, NotNil) + parsed, err := ParseSymlinksFile(obj.data) + t.Assert(err, IsNil) + t.Assert(parsed.HasSymlink("link1"), Equals, true) + t.Assert(parsed.HasSymlink("link2"), Equals, true) +} + +func (s *SymlinksTest) TestFlushPendingSymlinksNoopWhenEmpty(t *C) { + mock := newMockConditionalBackend() + _, parent := newTestDirInode(mock, 1*time.Hour) + + // No pending changes - should be a no-op + err := parent.FlushPendingSymlinks() + t.Assert(err, IsNil) + + // No S3 calls should have been made + t.Assert(len(mock.objects), Equals, 0) +} + +func (s *SymlinksTest) TestBatchTimerFiresAndFlushes(t *C) { + mock := newMockConditionalBackend() + _, parent := newTestDirInode(mock, 50*time.Millisecond) + + parent.mu.Lock() + + // Queue a change (timer should start) + err := parent.updateSymlinksFile("link1", "../target1", false) + t.Assert(err, IsNil) + + // Timer should be set + t.Assert(parent.dir.symlinkFlushTimer, NotNil) + + parent.mu.Unlock() + + // Wait for timer to fire + time.Sleep(200 * time.Millisecond) + + // Verify the flush happened + parent.mu.Lock() + t.Assert(len(parent.dir.pendingSymlinkChanges), Equals, 0) + parent.mu.Unlock() + + obj := mock.objects[".geesefs_symlinks"] + t.Assert(obj, NotNil) + parsed, err := ParseSymlinksFile(obj.data) + t.Assert(err, IsNil) + t.Assert(parsed.HasSymlink("link1"), Equals, true) +} + +func (s *SymlinksTest) TestBatchRemoveOnlySymlink(t *C) { + mock := newMockConditionalBackend() + _, parent := newTestDirInode(mock, 0) // immediate flush + + parent.mu.Lock() + + // Create a symlink + err := parent.updateSymlinksFile("link1", "../target1", false) + t.Assert(err, IsNil) + + // Remove it + err = parent.updateSymlinksFile("link1", "", true) + t.Assert(err, IsNil) + + // Empty data should have deleted the file + _, exists := mock.objects[".geesefs_symlinks"] + t.Assert(exists, Equals, false) + + parent.mu.Unlock() +} + diff --git a/docker/tests/symlinks_file/test_symlinks_file.sh b/docker/tests/symlinks_file/test_symlinks_file.sh index c72a07bf..6cafcc2b 100755 --- a/docker/tests/symlinks_file/test_symlinks_file.sh +++ b/docker/tests/symlinks_file/test_symlinks_file.sh @@ -642,6 +642,114 @@ fi cleanup_test_folder 9 +# ============================================================================== +log_header "TEST 10: Batch create — rapid symlinks all visible after flush" +# ============================================================================== + +setup_test_folder 10 + +log_info "Creating target files..." +for i in 1 2 3 4 5 6 7 8 9 10; do + run_in $MOUNT1 sh -c "echo 'target$i content' > $TEST_DIR/target$i.txt" +done +sync_and_wait + +log_info "Creating 10 symlinks in rapid succession..." +for i in 1 2 3 4 5 6 7 8 9 10; do + run_in $MOUNT1 ln -sf target$i.txt $TEST_DIR/link$i.txt +done + +log_info "Syncing to flush batched changes..." +run_in $MOUNT1 sync +sleep 3 + +log_info "Verifying all 10 symlinks in container 1..." +ALL_OK="yes" +for i in 1 2 3 4 5 6 7 8 9 10; do + CONTENT=$(run_in $MOUNT1 cat $TEST_DIR/link$i.txt 2>/dev/null || echo "FAILED") + if [ "$CONTENT" != "target$i content" ]; then + log_fail "link$i.txt content incorrect (got: $CONTENT)" + ALL_OK="no" + fi +done +if [ "$ALL_OK" = "yes" ]; then + log_pass "All 10 symlinks work correctly in container 1" +fi + +log_info "Checking .geesefs_symlinks file via S3 API..." +SYMLINKS_CONTENT=$(get_symlinks_file_s3 "test10") +if [ -n "$SYMLINKS_CONTENT" ]; then + LINK_COUNT=$(echo "$SYMLINKS_CONTENT" | grep -o '"link[0-9]*\.txt"' | wc -l) + LINK_COUNT=$(echo "$LINK_COUNT" | tr -d ' ') + if [ "$LINK_COUNT" = "10" ]; then + log_pass ".geesefs_symlinks contains all 10 symlink entries" + else + log_fail ".geesefs_symlinks contains $LINK_COUNT entries (expected 10)" + echo " Content:" + echo "$SYMLINKS_CONTENT" | sed 's/^/ /' + fi +else + log_fail ".geesefs_symlinks file not found in S3" +fi + +log_info "Waiting for cache refresh in container 2..." +sleep 5 +run_in $MOUNT2 ls -la $TEST_DIR/ >/dev/null 2>&1 || true +sleep 2 + +log_info "Verifying all 10 symlinks visible in container 2..." +ALL_OK="yes" +for i in 1 2 3 4 5 6 7 8 9 10; do + CONTENT=$(run_in $MOUNT2 cat $TEST_DIR/link$i.txt 2>/dev/null || echo "FAILED") + if [ "$CONTENT" != "target$i content" ]; then + log_fail "link$i.txt not visible/correct in container 2 (got: $CONTENT)" + ALL_OK="no" + fi +done +if [ "$ALL_OK" = "yes" ]; then + log_pass "All 10 symlinks visible and correct in container 2" +fi + +cleanup_test_folder 10 + +# ============================================================================== +log_header "TEST 11: Fsync flush — create symlinks then sync forces S3 persistence" +# ============================================================================== + +setup_test_folder 11 + +log_info "Creating target file..." +run_in $MOUNT1 sh -c "echo 'fsync test' > $TEST_DIR/target.txt" +sync_and_wait + +log_info "Creating symlinks rapidly..." +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/fsync_link1.txt +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/fsync_link2.txt +run_in $MOUNT1 ln -sf target.txt $TEST_DIR/fsync_link3.txt + +log_info "Calling sync to force S3 persistence..." +run_in $MOUNT1 sync +sleep 2 + +log_info "Verifying S3 has symlinks immediately after sync..." +SYMLINKS_CONTENT=$(get_symlinks_file_s3 "test11") +if [ -n "$SYMLINKS_CONTENT" ]; then + FOUND_ALL="yes" + for name in fsync_link1.txt fsync_link2.txt fsync_link3.txt; do + if ! echo "$SYMLINKS_CONTENT" | grep -q "$name"; then + log_fail ".geesefs_symlinks missing $name after sync" + FOUND_ALL="no" + fi + done + if [ "$FOUND_ALL" = "yes" ]; then + log_pass "All symlinks persisted in S3 after sync" + fi +else + log_fail ".geesefs_symlinks not found in S3 after sync" +fi + +cleanup_test_folder 11 + # ============================================================================== log_header "TEST SUMMARY" # ============================================================================== From 86fa5c72f6d8d4a76b04569adee93b06bb79157b Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Wed, 18 Feb 2026 11:34:13 +0100 Subject: [PATCH 31/39] fix: capture finalSize under lock to prevent race with concurrent writes completeMultipart() was reading Attributes.Size after its goroutine re-acquired inode.mu, but concurrent writes could extend the file in the gap between goroutine launch and lock acquisition. This caused finalSize to include parts not yet uploaded, leading to knownSize being set larger than the actual S3 object after CompleteMultipartUpload (which skips nil parts). The wrong knownSize then triggered spurious conflict detection and EINVAL errors on subsequent flushes. Capture finalSize in the caller while the lock is held and canComplete has verified all dirty parts are flushed. --- core/file.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/core/file.go b/core/file.go index df164e21..577359b3 100644 --- a/core/file.go +++ b/core/file.go @@ -745,13 +745,19 @@ func (inode *Inode) sendUpload(priority int) bool { canComplete = canComplete && !inode.IsRangeLocked(0, inode.Attributes.Size, true) if canComplete && (inode.fileHandles == 0 || inode.forceFlush || atomic.LoadInt32(&inode.fs.wantFree) > 0) { + // Capture finalSize now while we hold the lock and all parts are + // verified flushed. Reading Attributes.Size later (after the + // goroutine re-acquires the lock) would race with concurrent + // writes that extend the file, producing a finalSize that + // includes parts not yet uploaded. + finalSize := inode.Attributes.Size // Complete the multipart upload inode.IsFlushing += inode.fs.flags.MaxParallelParts atomic.AddInt64(&inode.fs.stats.flushes, 1) atomic.AddInt64(&inode.fs.activeFlushers, 1) go func() { inode.mu.Lock() - inode.completeMultipart() + inode.completeMultipart(finalSize) inode.IsFlushing -= inode.fs.flags.MaxParallelParts inode.mu.Unlock() atomic.AddInt64(&inode.fs.activeFlushers, -1) @@ -1731,13 +1737,12 @@ func (inode *Inode) flushPart(part uint64) { } // LOCKS_REQUIRED(inode.mu) -func (inode *Inode) completeMultipart() { +func (inode *Inode) completeMultipart(finalSize uint64) { // Server-side copy unmodified parts if inode.mpu == nil { // Multipart upload was canceled in the meantime (by a parallel conflict) => do not complete return } - finalSize := inode.Attributes.Size numParts := inode.fs.partNum(finalSize) numPartOffset, _ := inode.fs.partRange(numParts) if numPartOffset < finalSize { From b0145a9ae8bf7737daa82fc6edfe22f69b9e6bf9 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Wed, 18 Feb 2026 13:35:07 +0100 Subject: [PATCH 32/39] fix: use stable source size for UploadPartCopy range clipping knownSize grows as parts are flushed via updateFromFlush(), so by the time copyUnmodifiedParts runs it no longer reflects the actual S3 source object size. Capture knownSize at MPU begin as mpuSourceSize and clip copy ranges to that instead, preventing HTTP 400 (InvalidArgument) when copying ranges beyond the source object. --- core/file.go | 15 +++++++++------ core/handles.go | 4 ++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/core/file.go b/core/file.go index 577359b3..72546b98 100644 --- a/core/file.go +++ b/core/file.go @@ -1003,6 +1003,7 @@ func (inode *Inode) beginMultipartUpload(cloud StorageBackend, key string) { } else { log.Debugf("Started multi-part upload of object %v", key) inode.mpu = resp + inode.mpuSourceSize = inode.knownSize } } @@ -1555,15 +1556,17 @@ func (inode *Inode) copyUnmodifiedParts(numParts uint64) (err error) { for i := uint64(0); i < numParts; i++ { partOffset, partSize := inode.fs.partRange(i) // Skip parts that start at or beyond the source object size — they - // don't exist in S3 and can't be server-side copied - if partOffset >= inode.knownSize { + // don't exist in S3 and can't be server-side copied. + // Use mpuSourceSize (captured at MPU begin) instead of knownSize, + // because knownSize grows as parts are flushed via updateFromFlush. + if partOffset >= inode.mpuSourceSize { break } partEnd := partOffset + partSize - // Clip to the source object size, not Attributes.Size, because the - // file may have grown locally beyond the S3 object - if partEnd > inode.knownSize { - partEnd = inode.knownSize + // Clip to the source object size so UploadPartCopy doesn't request + // a range beyond the actual S3 object + if partEnd > inode.mpuSourceSize { + partEnd = inode.mpuSourceSize } if inode.mpu.Parts[i] == nil { if endPart == 0 { diff --git a/core/handles.go b/core/handles.go index 57052517..8332a0b2 100644 --- a/core/handles.go +++ b/core/handles.go @@ -140,6 +140,10 @@ type Inode struct { knownSize uint64 knownETag string + // size of the S3 source object when the multipart upload was initiated, + // used to clip UploadPartCopy ranges (knownSize grows as parts flush) + mpuSourceSize uint64 + // the refcnt is an exception, it's protected with atomic access // being part of parent.dir.Children increases refcnt by 1 refcnt int64 From d4f1ba11d04245adcae18dad517db86fd34a6a91 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Wed, 18 Feb 2026 15:03:26 +0100 Subject: [PATCH 33/39] fix: use HeadBlob to get actual source size for UploadPartCopy clipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach (mpuSourceSize captured from knownSize at MPU begin) fails when knownSize diverges from the actual S3 object size. This happens when updateFromFlush sets knownSize to the local file size after a completed MPU, but the resulting S3 object is smaller (e.g. due to clipped copies in a prior cycle). The next MPU then uses the wrong size for range clipping, causing S3 400 InvalidArgument. Instead, do a single HeadBlob at the start of copyUnmodifiedParts to get the ground-truth source object size from S3, and clip copy ranges to that. This adds one HEAD request per flush cycle that needs copies — negligible compared to the UploadPartCopy operations that follow. --- core/file.go | 43 ++++++++++++++++++++++++++++--------------- core/handles.go | 4 ---- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/core/file.go b/core/file.go index 72546b98..2c7e84d2 100644 --- a/core/file.go +++ b/core/file.go @@ -1003,7 +1003,6 @@ func (inode *Inode) beginMultipartUpload(cloud StorageBackend, key string) { } else { log.Debugf("Started multi-part upload of object %v", key) inode.mpu = resp - inode.mpuSourceSize = inode.knownSize } } @@ -1549,25 +1548,13 @@ func (inode *Inode) flushSmallObject() { func (inode *Inode) copyUnmodifiedParts(numParts uint64) (err error) { maxMerge := inode.fs.flags.MaxMergeCopyMB * 1024 * 1024 - // First collect ranges to be unaffected by sudden parallel changes + // First collect ranges of nil (unuploaded) parts var ranges []uint64 var startPart, endPart uint64 var startOffset, endOffset uint64 for i := uint64(0); i < numParts; i++ { partOffset, partSize := inode.fs.partRange(i) - // Skip parts that start at or beyond the source object size — they - // don't exist in S3 and can't be server-side copied. - // Use mpuSourceSize (captured at MPU begin) instead of knownSize, - // because knownSize grows as parts are flushed via updateFromFlush. - if partOffset >= inode.mpuSourceSize { - break - } partEnd := partOffset + partSize - // Clip to the source object size so UploadPartCopy doesn't request - // a range beyond the actual S3 object - if partEnd > inode.mpuSourceSize { - partEnd = inode.mpuSourceSize - } if inode.mpu.Parts[i] == nil { if endPart == 0 { startPart, startOffset = i, partOffset @@ -1596,7 +1583,33 @@ func (inode *Inode) copyUnmodifiedParts(numParts uint64) (err error) { guard := make(chan int, inode.fs.flags.MaxParallelCopy) var wg sync.WaitGroup inode.mu.Unlock() + // HeadBlob to get the actual S3 source object size. + // knownSize/mpuSourceSize can diverge from reality (e.g. after a + // completed MPU whose actual size differs from the local file size), + // so we always verify with a HEAD request. + var sourceSize uint64 + headResp, headErr := cloud.HeadBlob(&HeadBlobInput{Key: key}) + if headErr == nil { + sourceSize = headResp.Size + } else if mapAwsError(headErr) == syscall.ENOENT { + // Source object does not exist — nothing to copy + inode.mu.Lock() + return nil + } else { + log.Warnf("HeadBlob failed for %v during copyUnmodifiedParts: %v", key, headErr) + inode.mu.Lock() + return headErr + } for i := 0; i < len(ranges); i += 3 { + offset := ranges[i+1] + size := ranges[i+2] + // Clip copy range to the actual source object size + if offset >= sourceSize { + continue + } + if offset+size > sourceSize { + size = sourceSize - offset + } guard <- i if err != nil { break @@ -1629,7 +1642,7 @@ func (inode *Inode) copyUnmodifiedParts(numParts uint64) (err error) { } wg.Done() <-guard - }(uint64(ranges[i]), uint64(ranges[i+1]), uint64(ranges[i+2])) + }(uint64(ranges[i]), offset, size) } wg.Wait() inode.mu.Lock() diff --git a/core/handles.go b/core/handles.go index 8332a0b2..57052517 100644 --- a/core/handles.go +++ b/core/handles.go @@ -140,10 +140,6 @@ type Inode struct { knownSize uint64 knownETag string - // size of the S3 source object when the multipart upload was initiated, - // used to clip UploadPartCopy ranges (knownSize grows as parts flush) - mpuSourceSize uint64 - // the refcnt is an exception, it's protected with atomic access // being part of parent.dir.Children increases refcnt by 1 refcnt int64 From 933da63189499ad6439e01b7037221f196085197 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Wed, 18 Feb 2026 18:02:20 +0100 Subject: [PATCH 34/39] fix: harden UploadPartCopy range handling --- core/file.go | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/core/file.go b/core/file.go index 2c7e84d2..4a96d6f2 100644 --- a/core/file.go +++ b/core/file.go @@ -1545,16 +1545,22 @@ func (inode *Inode) flushSmallObject() { inode.mu.Unlock() } -func (inode *Inode) copyUnmodifiedParts(numParts uint64) (err error) { +func (inode *Inode) copyUnmodifiedParts(numParts, finalSize uint64) (err error) { maxMerge := inode.fs.flags.MaxMergeCopyMB * 1024 * 1024 - // First collect ranges of nil (unuploaded) parts + // First collect ranges of nil (unuploaded) parts. + // partRange() returns the full partition size for every part, but the + // last part of the file is typically shorter. Cap partEnd to finalSize + // so copy ranges never extend beyond the actual object boundary. var ranges []uint64 var startPart, endPart uint64 var startOffset, endOffset uint64 for i := uint64(0); i < numParts; i++ { partOffset, partSize := inode.fs.partRange(i) partEnd := partOffset + partSize + if partEnd > finalSize { + partEnd = finalSize + } if inode.mpu.Parts[i] == nil { if endPart == 0 { startPart, startOffset = i, partOffset @@ -1584,9 +1590,8 @@ func (inode *Inode) copyUnmodifiedParts(numParts uint64) (err error) { var wg sync.WaitGroup inode.mu.Unlock() // HeadBlob to get the actual S3 source object size. - // knownSize/mpuSourceSize can diverge from reality (e.g. after a - // completed MPU whose actual size differs from the local file size), - // so we always verify with a HEAD request. + // finalSize/local state can diverge from reality in conflict scenarios, + // so verify with HEAD and clip copy ranges to sourceSize. var sourceSize uint64 headResp, headErr := cloud.HeadBlob(&HeadBlobInput{Key: key}) if headErr == nil { @@ -1603,7 +1608,7 @@ func (inode *Inode) copyUnmodifiedParts(numParts uint64) (err error) { for i := 0; i < len(ranges); i += 3 { offset := ranges[i+1] size := ranges[i+2] - // Clip copy range to the actual source object size + // Clip copy range to actual source object boundaries if offset >= sourceSize { continue } @@ -1633,6 +1638,12 @@ func (inode *Inode) copyUnmodifiedParts(numParts uint64) (err error) { Size: size, }) if requestErr != nil { + // Treat stale source-size races as a conflict, not a hard EINVAL + if mapAwsError(requestErr) == syscall.EINVAL && + strings.Contains(requestErr.Error(), "InvalidArgument") && + strings.Contains(requestErr.Error(), "Range specified is not valid for source object of size") { + requestErr = syscall.ERANGE + } log.Warnf("Failed to copy unmodified range %v-%v MB of object %v: %v", offset/1024/1024, (offset+size+1024*1024-1)/1024/1024, key, requestErr) err = requestErr @@ -1764,7 +1775,7 @@ func (inode *Inode) completeMultipart(finalSize uint64) { if numPartOffset < finalSize { numParts++ } - err := inode.copyUnmodifiedParts(numParts) + err := inode.copyUnmodifiedParts(numParts, finalSize) if !(inode.CacheState == ST_CREATED || inode.CacheState == ST_MODIFIED) { // State changed, abort this flush (even if we get ENOENT) return From bfcfab46ebdeef2f87f08674f148eb271026abe0 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Wed, 18 Feb 2026 18:48:57 +0100 Subject: [PATCH 35/39] fix: zero-fill fallocate extensions --- core/goofys_fuse.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/core/goofys_fuse.go b/core/goofys_fuse.go index aecba087..c1195282 100644 --- a/core/goofys_fuse.go +++ b/core/goofys_fuse.go @@ -831,6 +831,7 @@ func (fs *GoofysFuse) Fallocate( if op.Offset+op.Length > inode.Attributes.Size { if (op.Mode & FALLOC_FL_KEEP_SIZE) == 0 { // Resize + oldSize := inode.Attributes.Size if op.Offset+op.Length > fs.getMaxFileSize() { // File size too large log.Warnf( @@ -842,6 +843,15 @@ func (fs *GoofysFuse) Fallocate( } inode.ResizeUnlocked(op.Offset+op.Length, true) modified = true + // Object storage can't represent sparse extensions without data. If we extend + // the file size via fallocate (mode 0), mark the new range as zero-filled so + // it will be uploaded and the final object size matches local size. + if (op.Mode&(FALLOC_FL_PUNCH_HOLE|FALLOC_FL_ZERO_RANGE)) == 0 { + if oldSize < op.Offset+op.Length { + zeroed, _ := inode.buffers.ZeroRange(oldSize, op.Offset+op.Length-oldSize) + modified = modified || zeroed + } + } } else { if op.Offset > inode.Attributes.Size { op.Offset = inode.Attributes.Size From d1ae566a24d4eaa7bcb2bc75caf8d12a064a703b Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Thu, 19 Feb 2026 13:37:13 +0100 Subject: [PATCH 36/39] perf: optimize symlink flush tracking and fallocate path Track only directories with pending symlink changes for sync/shutdown flushes and remove redundant fallocate extension zero-fill path. Co-Authored-By: Warp --- core/dir.go | 38 +++++++++++++++++++++++++++++ core/goofys.go | 58 ++++++++++++++++++++++++++------------------- core/goofys_fuse.go | 10 -------- 3 files changed, 72 insertions(+), 34 deletions(-) diff --git a/core/dir.go b/core/dir.go index ae6b52bc..5d0d57e5 100644 --- a/core/dir.go +++ b/core/dir.go @@ -1120,6 +1120,10 @@ func (inode *Inode) ResetForUnmount() { inode.dir.symlinkFlushTimer.Stop() inode.dir.symlinkFlushTimer = nil } + // Drop pending symlink queue bookkeeping for this directory. + inode.dir.pendingSymlinkChanges = nil + inode.dir.symlinkFlushETag = "" + inode.unmarkPendingSymlinkDirActiveLocked() // First reset the cloud info for this directory. After that, any read and // write operations under this directory will not know about this cloud. inode.dir.cloud = nil @@ -1651,6 +1655,33 @@ func (parent *Inode) CreateSymlink( return inode, nil } +// markPendingSymlinkDirActiveLocked adds this directory to the FS-level +// pending-symlink index. +// LOCKS_REQUIRED(parent.mu) +func (parent *Inode) markPendingSymlinkDirActiveLocked() { + if parent == nil || parent.fs == nil || parent.Id == 0 { + return + } + parent.fs.mu.Lock() + if parent.fs.activeSymlinkFlushDirs == nil { + parent.fs.activeSymlinkFlushDirs = make(map[fuseops.InodeID]*Inode) + } + parent.fs.activeSymlinkFlushDirs[parent.Id] = parent + parent.fs.mu.Unlock() +} + +// unmarkPendingSymlinkDirActiveLocked removes this directory from the FS-level +// pending-symlink index. +// LOCKS_REQUIRED(parent.mu) +func (parent *Inode) unmarkPendingSymlinkDirActiveLocked() { + if parent == nil || parent.fs == nil || parent.Id == 0 { + return + } + parent.fs.mu.Lock() + delete(parent.fs.activeSymlinkFlushDirs, parent.Id) + parent.fs.mu.Unlock() +} + // updateSymlinksFile updates the .symlinks file in this directory. // When SymlinksBatchDelay > 0, the in-memory cache is updated eagerly // and the S3 PUT is deferred. When SymlinksBatchDelay == 0, the S3 PUT @@ -1683,6 +1714,9 @@ func (parent *Inode) updateSymlinksFile(name string, target string, remove bool) if parent.fs.flags.SymlinksBatchDelay == 0 { // Batching disabled: flush immediately + if len(parent.dir.pendingSymlinkChanges) == 0 { + parent.markPendingSymlinkDirActiveLocked() + } parent.dir.pendingSymlinkChanges = append(parent.dir.pendingSymlinkChanges, PendingSymlinkChange{Name: name, Target: target, Remove: remove}) if parent.dir.symlinkFlushETag == "" && parent.dir.symlinksCacheETag != "" { @@ -1695,6 +1729,7 @@ func (parent *Inode) updateSymlinksFile(name string, target string, remove bool) if len(parent.dir.pendingSymlinkChanges) == 0 { // Capture ETag at time first change is queued parent.dir.symlinkFlushETag = parent.dir.symlinksCacheETag + parent.markPendingSymlinkDirActiveLocked() } parent.dir.pendingSymlinkChanges = append(parent.dir.pendingSymlinkChanges, PendingSymlinkChange{Name: name, Target: target, Remove: remove}) @@ -1724,6 +1759,7 @@ func (parent *Inode) flushSymlinksChanges() error { // Snapshot and clear pending changes changes := parent.dir.pendingSymlinkChanges parent.dir.pendingSymlinkChanges = nil + parent.unmarkPendingSymlinkDirActiveLocked() etag := parent.dir.symlinkFlushETag parent.dir.symlinkFlushETag = "" @@ -1758,6 +1794,7 @@ func (parent *Inode) flushSymlinksChanges() error { if err != nil { // Re-queue failed changes at the front so they are retried parent.dir.pendingSymlinkChanges = append(changes, parent.dir.pendingSymlinkChanges...) + parent.markPendingSymlinkDirActiveLocked() if parent.dir.symlinkFlushETag == "" { parent.dir.symlinkFlushETag = etag } @@ -1798,6 +1835,7 @@ func (parent *Inode) FlushPendingSymlinks() error { parent.mu.Lock() defer parent.mu.Unlock() if parent.dir == nil || len(parent.dir.pendingSymlinkChanges) == 0 { + parent.unmarkPendingSymlinkDirActiveLocked() return nil } return parent.flushSymlinksChanges() diff --git a/core/goofys.go b/core/goofys.go index b7fe848a..322904d6 100644 --- a/core/goofys.go +++ b/core/goofys.go @@ -96,6 +96,8 @@ type Goofys struct { inflightListingId int inflightListings map[int]map[string]bool inflightChanges map[string]int + // Directories currently having non-empty pendingSymlinkChanges queues. + activeSymlinkFlushDirs map[fuseops.InodeID]*Inode nextHandleID fuseops.HandleID dirHandles map[fuseops.HandleID]*DirHandle @@ -288,13 +290,14 @@ func newGoofys(ctx context.Context, bucket string, flags *cfg.FlagStorage, newBackend func(string, *cfg.FlagStorage) (StorageBackend, error)) (*Goofys, error) { // Set up the basic struct. fs := &Goofys{ - bucket: bucket, - flags: flags, - umask: 0122, - shutdownCh: make(chan struct{}), - zeroBuf: make([]byte, 1048576), - inflightChanges: make(map[string]int), - inflightListings: make(map[int]map[string]bool), + bucket: bucket, + flags: flags, + umask: 0122, + shutdownCh: make(chan struct{}), + zeroBuf: make([]byte, 1048576), + inflightChanges: make(map[string]int), + inflightListings: make(map[int]map[string]bool), + activeSymlinkFlushDirs: make(map[fuseops.InodeID]*Inode), stats: OpStats{ ts: time.Now(), }, @@ -400,20 +403,30 @@ func (fs *Goofys) Shutdown() { } } +// collectActiveSymlinkFlushDirs snapshots directories with pending symlink +// changes. When parent is non-nil, only directories in that subtree are kept. +func (fs *Goofys) collectActiveSymlinkFlushDirs(parent *Inode) []*Inode { + fs.mu.RLock() + inodes := make([]*Inode, 0, len(fs.activeSymlinkFlushDirs)) + for _, inode := range fs.activeSymlinkFlushDirs { + if inode == nil { + continue + } + if parent == nil || inode == parent || parent.isParentOf(inode) { + inodes = append(inodes, inode) + } + } + fs.mu.RUnlock() + return inodes +} + // flushAllPendingSymlinks walks all directory inodes and flushes any // pending batched symlink changes to S3. func (fs *Goofys) flushAllPendingSymlinks() { if !fs.flags.EnableSymlinksFile { return } - fs.mu.RLock() - inodes := make([]*Inode, 0) - for _, inode := range fs.inodes { - if inode.isDir() { - inodes = append(inodes, inode) - } - } - fs.mu.RUnlock() + inodes := fs.collectActiveSymlinkFlushDirs(nil) for _, inode := range inodes { if err := inode.FlushPendingSymlinks(); err != nil { s3Log.Warnf("flushAllPendingSymlinks: failed for %v: %v", inode.FullName(), err) @@ -1192,17 +1205,14 @@ func (fs *Goofys) SyncTree(parent *Inode) (err error) { fs.mu.RUnlock() if inode != nil { inode.SyncFile() - if fs.flags.EnableSymlinksFile && inode.isDir() { - if err := inode.FlushPendingSymlinks(); err != nil { - s3Log.Warnf("SyncTree: flush symlinks failed for %v: %v", inode.FullName(), err) - } - } } } - // Also flush the parent directory itself (isParentOf does not include self) - if parent != nil && fs.flags.EnableSymlinksFile && parent.isDir() { - if err := parent.FlushPendingSymlinks(); err != nil { - s3Log.Warnf("SyncTree: flush symlinks failed for %v: %v", parent.FullName(), err) + + if fs.flags.EnableSymlinksFile { + for _, inode := range fs.collectActiveSymlinkFlushDirs(parent) { + if err := inode.FlushPendingSymlinks(); err != nil { + s3Log.Warnf("SyncTree: flush symlinks failed for %v: %v", inode.FullName(), err) + } } } return diff --git a/core/goofys_fuse.go b/core/goofys_fuse.go index c1195282..aecba087 100644 --- a/core/goofys_fuse.go +++ b/core/goofys_fuse.go @@ -831,7 +831,6 @@ func (fs *GoofysFuse) Fallocate( if op.Offset+op.Length > inode.Attributes.Size { if (op.Mode & FALLOC_FL_KEEP_SIZE) == 0 { // Resize - oldSize := inode.Attributes.Size if op.Offset+op.Length > fs.getMaxFileSize() { // File size too large log.Warnf( @@ -843,15 +842,6 @@ func (fs *GoofysFuse) Fallocate( } inode.ResizeUnlocked(op.Offset+op.Length, true) modified = true - // Object storage can't represent sparse extensions without data. If we extend - // the file size via fallocate (mode 0), mark the new range as zero-filled so - // it will be uploaded and the final object size matches local size. - if (op.Mode&(FALLOC_FL_PUNCH_HOLE|FALLOC_FL_ZERO_RANGE)) == 0 { - if oldSize < op.Offset+op.Length { - zeroed, _ := inode.buffers.ZeroRange(oldSize, op.Offset+op.Length-oldSize) - modified = modified || zeroed - } - } } else { if op.Offset > inode.Attributes.Size { op.Offset = inode.Attributes.Size From ad04a741d8ee399b5dc416a3a4ca03a2ba405565 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Thu, 19 Feb 2026 13:12:06 +0100 Subject: [PATCH 37/39] fix: harden multipart copy source-size handling Capture finalSize under lock, use HeadBlob source-size validation, and fail fast on impossible unmodified-copy ranges to avoid copy thrash. Co-Authored-By: Warp --- core/file.go | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/core/file.go b/core/file.go index 4a96d6f2..74d41d31 100644 --- a/core/file.go +++ b/core/file.go @@ -745,11 +745,9 @@ func (inode *Inode) sendUpload(priority int) bool { canComplete = canComplete && !inode.IsRangeLocked(0, inode.Attributes.Size, true) if canComplete && (inode.fileHandles == 0 || inode.forceFlush || atomic.LoadInt32(&inode.fs.wantFree) > 0) { - // Capture finalSize now while we hold the lock and all parts are - // verified flushed. Reading Attributes.Size later (after the - // goroutine re-acquires the lock) would race with concurrent - // writes that extend the file, producing a finalSize that - // includes parts not yet uploaded. + // Capture finalSize now while we hold inode.mu and all parts are + // verified flushed. Reading Attributes.Size later can race with + // concurrent writes that extend the file and include parts not yet uploaded. finalSize := inode.Attributes.Size // Complete the multipart upload inode.IsFlushing += inode.fs.flags.MaxParallelParts @@ -1551,10 +1549,11 @@ func (inode *Inode) copyUnmodifiedParts(numParts, finalSize uint64) (err error) // First collect ranges of nil (unuploaded) parts. // partRange() returns the full partition size for every part, but the // last part of the file is typically shorter. Cap partEnd to finalSize - // so copy ranges never extend beyond the actual object boundary. + // so copy ranges never extend beyond the intended final object boundary. var ranges []uint64 var startPart, endPart uint64 var startOffset, endOffset uint64 + var maxRequiredSource uint64 for i := uint64(0); i < numParts; i++ { partOffset, partSize := inode.fs.partRange(i) partEnd := partOffset + partSize @@ -1566,6 +1565,9 @@ func (inode *Inode) copyUnmodifiedParts(numParts, finalSize uint64) (err error) startPart, startOffset = i, partOffset } endPart, endOffset = i+1, partEnd + if endOffset > maxRequiredSource { + maxRequiredSource = endOffset + } if endOffset-startOffset >= maxMerge { ranges = append(ranges, startPart, startOffset, endOffset-startOffset) startPart, endPart = 0, 0 @@ -1589,7 +1591,7 @@ func (inode *Inode) copyUnmodifiedParts(numParts, finalSize uint64) (err error) guard := make(chan int, inode.fs.flags.MaxParallelCopy) var wg sync.WaitGroup inode.mu.Unlock() - // HeadBlob to get the actual S3 source object size. + // HeadBlob to get the actual source object size before UploadPartCopy. // finalSize/local state can diverge from reality in conflict scenarios, // so verify with HEAD and clip copy ranges to sourceSize. var sourceSize uint64 @@ -1605,10 +1607,20 @@ func (inode *Inode) copyUnmodifiedParts(numParts, finalSize uint64) (err error) inode.mu.Lock() return headErr } + // Fail fast: if pending unmodified-copy ranges require bytes beyond + // the source object, copying cannot produce a consistent final object. + if maxRequiredSource > sourceSize { + log.Warnf( + "Source object %v is smaller than required for unmodified copy (source=%v, required=%v), treating as conflict", + key, sourceSize, maxRequiredSource, + ) + inode.mu.Lock() + return syscall.ERANGE + } for i := 0; i < len(ranges); i += 3 { offset := ranges[i+1] size := ranges[i+2] - // Clip copy range to actual source object boundaries + // Clip copy range to actual source object boundaries. if offset >= sourceSize { continue } @@ -1638,7 +1650,7 @@ func (inode *Inode) copyUnmodifiedParts(numParts, finalSize uint64) (err error) Size: size, }) if requestErr != nil { - // Treat stale source-size races as a conflict, not a hard EINVAL + // Treat stale source-size races as conflict, not hard EINVAL. if mapAwsError(requestErr) == syscall.EINVAL && strings.Contains(requestErr.Error(), "InvalidArgument") && strings.Contains(requestErr.Error(), "Range specified is not valid for source object of size") { From cb9d08612f10db1dd34e5c1615b83911c851f01d Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Thu, 19 Feb 2026 19:23:56 +0100 Subject: [PATCH 38/39] fix: restore fallocate extension zero-fill Co-Authored-By: Warp --- core/goofys_fuse.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/core/goofys_fuse.go b/core/goofys_fuse.go index aecba087..c1195282 100644 --- a/core/goofys_fuse.go +++ b/core/goofys_fuse.go @@ -831,6 +831,7 @@ func (fs *GoofysFuse) Fallocate( if op.Offset+op.Length > inode.Attributes.Size { if (op.Mode & FALLOC_FL_KEEP_SIZE) == 0 { // Resize + oldSize := inode.Attributes.Size if op.Offset+op.Length > fs.getMaxFileSize() { // File size too large log.Warnf( @@ -842,6 +843,15 @@ func (fs *GoofysFuse) Fallocate( } inode.ResizeUnlocked(op.Offset+op.Length, true) modified = true + // Object storage can't represent sparse extensions without data. If we extend + // the file size via fallocate (mode 0), mark the new range as zero-filled so + // it will be uploaded and the final object size matches local size. + if (op.Mode&(FALLOC_FL_PUNCH_HOLE|FALLOC_FL_ZERO_RANGE)) == 0 { + if oldSize < op.Offset+op.Length { + zeroed, _ := inode.buffers.ZeroRange(oldSize, op.Offset+op.Length-oldSize) + modified = modified || zeroed + } + } } else { if op.Offset > inode.Attributes.Size { op.Offset = inode.Attributes.Size From ae9d66974b3f87cfac62ad1a753f8e51d4ce7b60 Mon Sep 17 00:00:00 2001 From: Alexandre Manhaes Savio Date: Mon, 2 Mar 2026 10:40:21 +0100 Subject: [PATCH 39/39] chore: set version number for this release --- core/cfg/flags.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/cfg/flags.go b/core/cfg/flags.go index 33ce2f2f..e3a13a73 100644 --- a/core/cfg/flags.go +++ b/core/cfg/flags.go @@ -30,7 +30,7 @@ import ( "github.com/urfave/cli" ) -const GEESEFS_VERSION = "0.44.0" +const GEESEFS_VERSION = "0.43.0-dc.2" var flagCategories map[string]string