* dlm: replace modulo hashing with consistent hash ring Introduce HashRing with virtual nodes (CRC32-based consistent hashing) to replace the modulo-based hashKeyToServer. When a filer node is removed, only keys that hashed to that node are remapped to the next server on the ring, leaving all other mappings stable. This is the foundation for backup replication — the successor on the ring is always the natural takeover node. * dlm: add Generation and IsBackup fields to Lock Lock now carries IsBackup (whether this node holds the lock as a backup replica) and Generation (a monotonic fencing token that increments on each fresh acquisition, stays the same on renewal). Add helper methods: AllLocks, PromoteLock, DemoteLock, InsertBackupLock, RemoveLock, GetLock. * dlm: add ReplicateLock RPC and generation/is_backup proto fields Add generation field to LockResponse for fencing tokens. Add generation and is_backup fields to Lock message. Add ReplicateLock RPC for primary-to-backup lock replication. Add ReplicateLockRequest/ReplicateLockResponse messages. * dlm: add async backup replication to DistributedLockManager Route lock/unlock via consistent hash ring's GetPrimaryAndBackup(). After a successful lock or unlock on the primary, asynchronously replicate the operation to the backup server via ReplicateFunc callback. Single-server deployments skip replication. * dlm: add ReplicateLock handler and backup-aware topology changes Add ReplicateLock gRPC handler for primary-to-backup replication. Revise OnDlmChangeSnapshot to handle three cases on topology change: - Promote backup locks when this node becomes primary - Demote primary locks when this node becomes backup - Transfer locks when this node is neither primary nor backup Wire up SetupDlmReplication during filer server initialization. * dlm: expose generation fencing token in lock client LiveLock now captures the generation from LockResponse and exposes it via Generation() method. Consumers can use this as a fencing token to detect stale lock holders. * dlm: update empty folder cleaner to use consistent hash ring Replace local modulo-based hashKeyToServer with LockRing.GetPrimary() which uses the shared consistent hash ring for folder ownership. * dlm: add unit tests for consistent hash ring Test basic operations, consistency on server removal (only keys from removed server move), backup-is-successor property (backup becomes new primary when primary is removed), and key distribution balance. * dlm: add integration tests for lock replication failure scenarios Test cases: - Primary crash with backup promotion (backup has valid token) - Backup crash with primary continuing - Both primary and backup crash (lock lost, re-acquirable) - Rolling restart across all nodes - Generation fencing token increments on new acquisition - Replication failure (primary still works independently) - Unlock replicates deletion to backup - Lock survives server addition (topology change) - Consistent hashing minimal disruption (only removed server's keys move) * dlm: address PR review findings 1. Causal replication ordering: Add per-lock sequence number (Seq) that increments on every mutation. Backup rejects incoming mutations with seq <= current seq, preventing stale async replications from overwriting newer state. Unlock replication also carries seq and is rejected if stale. 2. Demote-after-handoff: OnDlmChangeSnapshot now transfers the lock to the new primary first and only demotes to backup after a successful TransferLocks RPC. If the transfer fails, the lock stays as primary on this node. 3. SetSnapshot candidateServers leak: Replace the candidateServers map entirely instead of appending, so removed servers don't linger. 4. TransferLocks preserves Generation and Seq: InsertLock now accepts generation and seq parameters. After accepting a transferred lock, the receiving node re-replicates to its backup. 5. Rolling restart test: Add re-replication step after promotion and assert survivedCount > 0. Add TestDLM_StaleReplicationRejected. 6. Mixed-version upgrade note: Add comment on HashRing documenting that all filer nodes must be upgraded together. * dlm: serve renewals locally during transfer window on node join When a new node joins and steals hash ranges from surviving nodes, there's a window between ring update and lock transfer where the client gets redirected to a node that doesn't have the lock yet. Fix: if the ring says primary != self but we still hold the lock locally (non-backup, matching token), serve the renewal/unlock here rather than redirecting. The lock will be transferred by OnDlmChangeSnapshot, and subsequent requests will go to the new primary once the transfer completes. Add tests: - TestDLM_NodeDropAndJoin_OwnershipDisruption: measures disruption when a node drops and a new one joins (14/100 surviving-node locks disrupted, all handled by transfer logic) - TestDLM_RenewalDuringTransferWindow: verifies renewal succeeds on old primary during the transfer window * dlm: master-managed lock ring with stabilization batching The master now owns the lock ring membership. Instead of filers independently reacting to individual ClusterNodeUpdate add/remove events, the master: 1. Tracks filer membership in LockRingManager 2. Batches rapid changes with a 1-second stabilization timer (e.g., a node drop + join within 1 second → single ring update) 3. Broadcasts the complete ring snapshot atomically via the new LockRingUpdate message in KeepConnectedResponse Filers receive the ring as a complete snapshot and apply it via SetSnapshot, ensuring all filers converge to the same ring state without intermediate churn. This eliminates the double-churn problem where a rapid drop+join would fire two separate ring mutations, each triggering lock transfers and disrupting ownership on surviving nodes. * dlm: track ring version, reject stale updates, remove dead code SetSnapshot now takes a version parameter from the master. Stale updates (version < current) are rejected, preventing reordered messages from overwriting a newer ring state. Version 0 is always accepted for bootstrap. Remove AddServer/RemoveServer from LockRing — the ring is now exclusively managed by the master via SetSnapshot. Remove the candidateServers map that was only used by those methods. * dlm: fix SelectLocks data race, advance generation on backup insert - SelectLocks: change RLock to Lock since the function deletes map entries, which is a write operation and causes a data race under RLock. - InsertBackupLock: advance nextGeneration to at least the incoming generation so that after failover promotion, new lock acquisitions get a generation strictly greater than any replicated lock. - Bump replication failure log from V(1) to Warningf for production visibility. * dlm: fix SetSnapshot race, test reliability, timer edge cases - SetSnapshot: hold LockRing lock through both version update and Ring.SetServers() so they're atomic. Prevents a concurrent caller from seeing the new version but applying stale servers. - Transfer window test: search for a key that actually moves primary when filer4 joins, instead of relying on a fixed key that may not. - renewLock redirect: pass the existing token to the new primary instead of empty string, so redirected renewals work correctly. - scheduleBroadcast: check timer.Stop() return value. If the timer already fired, the callback picks up latest state. - FlushPending: only broadcast if timer.Stop() returns true (timer was still pending). If false, the callback is already running. - Fix test comment: "idempotent" → "accepted, state-changing". * dlm: use wall-clock nanoseconds for lock ring version The lock ring version was an in-memory counter that reset to 0 on master restart. A filer that had seen version 5 would reject version 1 from the restarted master. Fix: use time.Now().UnixNano() as the version. This survives master restarts without persistence — the restarted master produces a version greater than any pre-restart value. * dlm: treat expired lock owners as missing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * dlm: reject stale lock transfers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * dlm: order replication by generation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * dlm: bootstrap lock ring on reconnect Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
811 lines
23 KiB
Go
811 lines
23 KiB
Go
package empty_folder_cleanup
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/cluster/lock_manager"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
)
|
|
|
|
type mockFilerOps struct {
|
|
countFn func(path util.FullPath) (int, error)
|
|
deleteFn func(path util.FullPath) error
|
|
attrsFn func(path util.FullPath) (map[string][]byte, error)
|
|
isDirKeyObjFn func(path util.FullPath) (bool, error)
|
|
}
|
|
|
|
func (m *mockFilerOps) CountDirectoryEntries(_ context.Context, dirPath util.FullPath, _ int) (int, error) {
|
|
if m.countFn == nil {
|
|
return 0, nil
|
|
}
|
|
return m.countFn(dirPath)
|
|
}
|
|
|
|
func (m *mockFilerOps) DeleteEntryMetaAndData(_ context.Context, p util.FullPath, _, _, _, _ bool, _ []int32, _ int64) error {
|
|
if m.deleteFn == nil {
|
|
return nil
|
|
}
|
|
return m.deleteFn(p)
|
|
}
|
|
|
|
func (m *mockFilerOps) GetEntryAttributes(_ context.Context, p util.FullPath) (map[string][]byte, error) {
|
|
if m.attrsFn == nil {
|
|
return nil, nil
|
|
}
|
|
return m.attrsFn(p)
|
|
}
|
|
|
|
func (m *mockFilerOps) IsDirectoryKeyObject(_ context.Context, p util.FullPath) (bool, error) {
|
|
if m.isDirKeyObjFn == nil {
|
|
return false, nil
|
|
}
|
|
return m.isDirKeyObjFn(p)
|
|
}
|
|
|
|
func Test_isUnderPath(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
child string
|
|
parent string
|
|
expected bool
|
|
}{
|
|
{"child under parent", "/buckets/mybucket/folder/file.txt", "/buckets", true},
|
|
{"child is parent", "/buckets", "/buckets", true},
|
|
{"child not under parent", "/other/path", "/buckets", false},
|
|
{"empty parent", "/any/path", "", true},
|
|
{"root parent", "/any/path", "/", true},
|
|
{"parent with trailing slash", "/buckets/mybucket", "/buckets/", true},
|
|
{"similar prefix but not under", "/buckets-other/file", "/buckets", false},
|
|
{"deeply nested", "/buckets/a/b/c/d/e/f", "/buckets/a/b", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := isUnderPath(tt.child, tt.parent)
|
|
if result != tt.expected {
|
|
t.Errorf("isUnderPath(%q, %q) = %v, want %v", tt.child, tt.parent, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_isUnderBucketPath(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
directory string
|
|
bucketPath string
|
|
expected bool
|
|
}{
|
|
// Should NOT process - bucket path itself
|
|
{"bucket path itself", "/buckets", "/buckets", false},
|
|
// Should NOT process - bucket directory (immediate child)
|
|
{"bucket directory", "/buckets/mybucket", "/buckets", false},
|
|
// Should process - folder inside bucket
|
|
{"folder in bucket", "/buckets/mybucket/folder", "/buckets", true},
|
|
// Should process - nested folder
|
|
{"nested folder", "/buckets/mybucket/a/b/c", "/buckets", true},
|
|
// Should NOT process - outside buckets
|
|
{"outside buckets", "/other/path", "/buckets", false},
|
|
// Empty bucket path allows all
|
|
{"empty bucket path", "/any/path", "", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := isUnderBucketPath(tt.directory, tt.bucketPath)
|
|
if result != tt.expected {
|
|
t.Errorf("isUnderBucketPath(%q, %q) = %v, want %v", tt.directory, tt.bucketPath, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_autoRemoveEmptyFoldersEnabled(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
attrs map[string][]byte
|
|
enabled bool
|
|
attrValue string
|
|
}{
|
|
{
|
|
name: "no attrs defaults enabled",
|
|
attrs: nil,
|
|
enabled: true,
|
|
attrValue: "<no_attrs>",
|
|
},
|
|
{
|
|
name: "missing key defaults enabled",
|
|
attrs: map[string][]byte{},
|
|
enabled: true,
|
|
attrValue: "<missing>",
|
|
},
|
|
{
|
|
name: "allow-empty disables cleanup",
|
|
attrs: map[string][]byte{
|
|
s3_constants.ExtAllowEmptyFolders: []byte("true"),
|
|
},
|
|
enabled: false,
|
|
attrValue: "true",
|
|
},
|
|
{
|
|
name: "explicit false keeps cleanup enabled",
|
|
attrs: map[string][]byte{
|
|
s3_constants.ExtAllowEmptyFolders: []byte("false"),
|
|
},
|
|
enabled: true,
|
|
attrValue: "false",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
enabled, attrValue := autoRemoveEmptyFoldersEnabled(tt.attrs)
|
|
if enabled != tt.enabled {
|
|
t.Fatalf("expected enabled=%v, got %v", tt.enabled, enabled)
|
|
}
|
|
if attrValue != tt.attrValue {
|
|
t.Fatalf("expected attrValue=%q, got %q", tt.attrValue, attrValue)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_ownsFolder(t *testing.T) {
|
|
// Create a LockRing with multiple servers
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
|
|
servers := []pb.ServerAddress{
|
|
"filer1:8888",
|
|
"filer2:8888",
|
|
"filer3:8888",
|
|
}
|
|
lockRing.SetSnapshot(servers, 0)
|
|
|
|
// Create cleaner for filer1
|
|
cleaner1 := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
}
|
|
|
|
// Create cleaner for filer2
|
|
cleaner2 := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer2:8888",
|
|
}
|
|
|
|
// Create cleaner for filer3
|
|
cleaner3 := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer3:8888",
|
|
}
|
|
|
|
// Test that exactly one filer owns each folder
|
|
testFolders := []string{
|
|
"/buckets/mybucket/folder1",
|
|
"/buckets/mybucket/folder2",
|
|
"/buckets/mybucket/folder3",
|
|
"/buckets/mybucket/a/b/c",
|
|
"/buckets/otherbucket/x",
|
|
}
|
|
|
|
for _, folder := range testFolders {
|
|
ownCount := 0
|
|
if cleaner1.ownsFolder(folder) {
|
|
ownCount++
|
|
}
|
|
if cleaner2.ownsFolder(folder) {
|
|
ownCount++
|
|
}
|
|
if cleaner3.ownsFolder(folder) {
|
|
ownCount++
|
|
}
|
|
|
|
if ownCount != 1 {
|
|
t.Errorf("folder %q owned by %d filers, expected exactly 1", folder, ownCount)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_ownsFolder_singleServer(t *testing.T) {
|
|
// Create a LockRing with a single server
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}, 0)
|
|
|
|
cleaner := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
}
|
|
|
|
// Single filer should own all folders
|
|
testFolders := []string{
|
|
"/buckets/mybucket/folder1",
|
|
"/buckets/mybucket/folder2",
|
|
"/buckets/otherbucket/x",
|
|
}
|
|
|
|
for _, folder := range testFolders {
|
|
if !cleaner.ownsFolder(folder) {
|
|
t.Errorf("single filer should own folder %q", folder)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_ownsFolder_emptyRing(t *testing.T) {
|
|
// Create an empty LockRing
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
|
|
cleaner := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
}
|
|
|
|
// With empty ring, should own all folders
|
|
if !cleaner.ownsFolder("/buckets/mybucket/folder") {
|
|
t.Error("should own folder with empty ring")
|
|
}
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_OnCreateEvent_cancelsCleanup(t *testing.T) {
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}, 0)
|
|
|
|
cleaner := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
bucketPath: "/buckets",
|
|
enabled: true,
|
|
folderCounts: make(map[string]*folderState),
|
|
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
folder := "/buckets/mybucket/testfolder"
|
|
now := time.Now()
|
|
|
|
// Simulate delete event
|
|
cleaner.OnDeleteEvent(folder, "file.txt", false, now)
|
|
|
|
// Check that cleanup is queued
|
|
if cleaner.GetPendingCleanupCount() != 1 {
|
|
t.Errorf("expected 1 pending cleanup, got %d", cleaner.GetPendingCleanupCount())
|
|
}
|
|
|
|
// Simulate create event
|
|
cleaner.OnCreateEvent(folder, "newfile.txt", false)
|
|
|
|
// Check that cleanup is cancelled
|
|
if cleaner.GetPendingCleanupCount() != 0 {
|
|
t.Errorf("expected 0 pending cleanups after create, got %d", cleaner.GetPendingCleanupCount())
|
|
}
|
|
|
|
cleaner.Stop()
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_OnDeleteEvent_deduplication(t *testing.T) {
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}, 0)
|
|
|
|
cleaner := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
bucketPath: "/buckets",
|
|
enabled: true,
|
|
folderCounts: make(map[string]*folderState),
|
|
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
folder := "/buckets/mybucket/testfolder"
|
|
now := time.Now()
|
|
|
|
// Simulate multiple delete events for same folder
|
|
for i := 0; i < 5; i++ {
|
|
cleaner.OnDeleteEvent(folder, "file"+string(rune('0'+i))+".txt", false, now.Add(time.Duration(i)*time.Second))
|
|
}
|
|
|
|
// Check that only 1 cleanup is queued (deduplicated)
|
|
if cleaner.GetPendingCleanupCount() != 1 {
|
|
t.Errorf("expected 1 pending cleanup after deduplication, got %d", cleaner.GetPendingCleanupCount())
|
|
}
|
|
|
|
cleaner.Stop()
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_OnDeleteEvent_multipleFolders(t *testing.T) {
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}, 0)
|
|
|
|
cleaner := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
bucketPath: "/buckets",
|
|
enabled: true,
|
|
folderCounts: make(map[string]*folderState),
|
|
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
// Delete files in different folders
|
|
cleaner.OnDeleteEvent("/buckets/mybucket/folder1", "file.txt", false, now)
|
|
cleaner.OnDeleteEvent("/buckets/mybucket/folder2", "file.txt", false, now.Add(1*time.Second))
|
|
cleaner.OnDeleteEvent("/buckets/mybucket/folder3", "file.txt", false, now.Add(2*time.Second))
|
|
|
|
// Each folder should be queued
|
|
if cleaner.GetPendingCleanupCount() != 3 {
|
|
t.Errorf("expected 3 pending cleanups, got %d", cleaner.GetPendingCleanupCount())
|
|
}
|
|
|
|
cleaner.Stop()
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_OnDeleteEvent_notOwner(t *testing.T) {
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888", "filer2:8888"}, 0)
|
|
|
|
// Create cleaner for filer that doesn't own the folder
|
|
cleaner := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
bucketPath: "/buckets",
|
|
enabled: true,
|
|
folderCounts: make(map[string]*folderState),
|
|
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
// Try many folders, looking for one that filer1 doesn't own
|
|
foundNonOwned := false
|
|
for i := 0; i < 100; i++ {
|
|
folder := "/buckets/mybucket/folder" + string(rune('0'+i%10)) + string(rune('0'+i/10))
|
|
if !cleaner.ownsFolder(folder) {
|
|
// This folder is not owned by filer1
|
|
cleaner.OnDeleteEvent(folder, "file.txt", false, now)
|
|
if cleaner.GetPendingCleanupCount() != 0 {
|
|
t.Errorf("non-owner should not queue cleanup for folder %s", folder)
|
|
}
|
|
foundNonOwned = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !foundNonOwned {
|
|
t.Skip("could not find a folder not owned by filer1")
|
|
}
|
|
|
|
cleaner.Stop()
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_OnDeleteEvent_disabled(t *testing.T) {
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}, 0)
|
|
|
|
cleaner := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
bucketPath: "/buckets",
|
|
enabled: false, // Disabled
|
|
folderCounts: make(map[string]*folderState),
|
|
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
folder := "/buckets/mybucket/testfolder"
|
|
now := time.Now()
|
|
|
|
// Simulate delete event
|
|
cleaner.OnDeleteEvent(folder, "file.txt", false, now)
|
|
|
|
// Check that no cleanup is queued when disabled
|
|
if cleaner.GetPendingCleanupCount() != 0 {
|
|
t.Errorf("disabled cleaner should not queue cleanup, got %d", cleaner.GetPendingCleanupCount())
|
|
}
|
|
|
|
cleaner.Stop()
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_OnDeleteEvent_directoryDeletion(t *testing.T) {
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}, 0)
|
|
|
|
cleaner := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
bucketPath: "/buckets",
|
|
enabled: true,
|
|
folderCounts: make(map[string]*folderState),
|
|
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
folder := "/buckets/mybucket/testfolder"
|
|
now := time.Now()
|
|
|
|
// Simulate directory delete event - should trigger cleanup
|
|
// because subdirectory deletion also makes parent potentially empty
|
|
cleaner.OnDeleteEvent(folder, "subdir", true, now)
|
|
|
|
// Check that cleanup IS queued for directory deletion
|
|
if cleaner.GetPendingCleanupCount() != 1 {
|
|
t.Errorf("directory deletion should trigger cleanup, got %d", cleaner.GetPendingCleanupCount())
|
|
}
|
|
|
|
cleaner.Stop()
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_cachedCounts(t *testing.T) {
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}, 0)
|
|
|
|
cleaner := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
bucketPath: "/buckets",
|
|
enabled: true,
|
|
folderCounts: make(map[string]*folderState),
|
|
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
folder := "/buckets/mybucket/testfolder"
|
|
|
|
// Initialize cached count
|
|
cleaner.folderCounts[folder] = &folderState{roughCount: 5}
|
|
|
|
// Simulate create events
|
|
cleaner.OnCreateEvent(folder, "newfile1.txt", false)
|
|
cleaner.OnCreateEvent(folder, "newfile2.txt", false)
|
|
|
|
// Check cached count increased
|
|
count, exists := cleaner.GetCachedFolderCount(folder)
|
|
if !exists {
|
|
t.Error("cached folder count should exist")
|
|
}
|
|
if count != 7 {
|
|
t.Errorf("expected cached count 7, got %d", count)
|
|
}
|
|
|
|
// Simulate delete events
|
|
now := time.Now()
|
|
cleaner.OnDeleteEvent(folder, "file1.txt", false, now)
|
|
cleaner.OnDeleteEvent(folder, "file2.txt", false, now.Add(1*time.Second))
|
|
|
|
// Check cached count decreased
|
|
count, exists = cleaner.GetCachedFolderCount(folder)
|
|
if !exists {
|
|
t.Error("cached folder count should exist")
|
|
}
|
|
if count != 5 {
|
|
t.Errorf("expected cached count 5, got %d", count)
|
|
}
|
|
|
|
cleaner.Stop()
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_Stop(t *testing.T) {
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}, 0)
|
|
|
|
cleaner := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
bucketPath: "/buckets",
|
|
enabled: true,
|
|
folderCounts: make(map[string]*folderState),
|
|
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
// Queue some cleanups
|
|
cleaner.OnDeleteEvent("/buckets/mybucket/folder1", "file1.txt", false, now)
|
|
cleaner.OnDeleteEvent("/buckets/mybucket/folder2", "file2.txt", false, now.Add(1*time.Second))
|
|
cleaner.OnDeleteEvent("/buckets/mybucket/folder3", "file3.txt", false, now.Add(2*time.Second))
|
|
|
|
// Verify cleanups are queued
|
|
if cleaner.GetPendingCleanupCount() < 1 {
|
|
t.Error("expected at least 1 pending cleanup before stop")
|
|
}
|
|
|
|
// Stop the cleaner
|
|
cleaner.Stop()
|
|
|
|
// Verify all cleanups are cancelled
|
|
if cleaner.GetPendingCleanupCount() != 0 {
|
|
t.Errorf("expected 0 pending cleanups after stop, got %d", cleaner.GetPendingCleanupCount())
|
|
}
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_cacheEviction(t *testing.T) {
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}, 0)
|
|
|
|
cleaner := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
bucketPath: "/buckets",
|
|
enabled: true,
|
|
folderCounts: make(map[string]*folderState),
|
|
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute),
|
|
cacheExpiry: 100 * time.Millisecond, // Short expiry for testing
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
folder1 := "/buckets/mybucket/folder1"
|
|
folder2 := "/buckets/mybucket/folder2"
|
|
folder3 := "/buckets/mybucket/folder3"
|
|
|
|
// Add some cache entries with old timestamps
|
|
oldTime := time.Now().Add(-1 * time.Hour)
|
|
cleaner.folderCounts[folder1] = &folderState{roughCount: 5, lastCheck: oldTime}
|
|
cleaner.folderCounts[folder2] = &folderState{roughCount: 3, lastCheck: oldTime}
|
|
// folder3 has recent activity
|
|
cleaner.folderCounts[folder3] = &folderState{roughCount: 2, lastCheck: time.Now()}
|
|
|
|
// Verify all entries exist
|
|
if len(cleaner.folderCounts) != 3 {
|
|
t.Errorf("expected 3 cache entries, got %d", len(cleaner.folderCounts))
|
|
}
|
|
|
|
// Run eviction
|
|
cleaner.evictStaleCacheEntries()
|
|
|
|
// Verify stale entries are evicted
|
|
if len(cleaner.folderCounts) != 1 {
|
|
t.Errorf("expected 1 cache entry after eviction, got %d", len(cleaner.folderCounts))
|
|
}
|
|
|
|
// Verify the recent entry still exists
|
|
if _, exists := cleaner.folderCounts[folder3]; !exists {
|
|
t.Error("expected folder3 to still exist in cache")
|
|
}
|
|
|
|
// Verify stale entries are removed
|
|
if _, exists := cleaner.folderCounts[folder1]; exists {
|
|
t.Error("expected folder1 to be evicted")
|
|
}
|
|
if _, exists := cleaner.folderCounts[folder2]; exists {
|
|
t.Error("expected folder2 to be evicted")
|
|
}
|
|
|
|
cleaner.Stop()
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_cacheEviction_skipsEntriesInQueue(t *testing.T) {
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}, 0)
|
|
|
|
cleaner := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
bucketPath: "/buckets",
|
|
enabled: true,
|
|
folderCounts: make(map[string]*folderState),
|
|
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute),
|
|
cacheExpiry: 100 * time.Millisecond,
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
folder := "/buckets/mybucket/folder"
|
|
oldTime := time.Now().Add(-1 * time.Hour)
|
|
|
|
// Add a stale cache entry
|
|
cleaner.folderCounts[folder] = &folderState{roughCount: 0, lastCheck: oldTime}
|
|
// Also add to cleanup queue
|
|
cleaner.cleanupQueue.Add(folder, "item", time.Now())
|
|
|
|
// Run eviction
|
|
cleaner.evictStaleCacheEntries()
|
|
|
|
// Verify entry is NOT evicted because it's in cleanup queue
|
|
if _, exists := cleaner.folderCounts[folder]; !exists {
|
|
t.Error("expected folder to still exist in cache (is in cleanup queue)")
|
|
}
|
|
|
|
cleaner.Stop()
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_queueFIFOOrder(t *testing.T) {
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}, 0)
|
|
|
|
cleaner := &EmptyFolderCleaner{
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
bucketPath: "/buckets",
|
|
enabled: true,
|
|
folderCounts: make(map[string]*folderState),
|
|
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
// Add folders in order
|
|
folders := []string{
|
|
"/buckets/mybucket/folder1",
|
|
"/buckets/mybucket/folder2",
|
|
"/buckets/mybucket/folder3",
|
|
}
|
|
for i, folder := range folders {
|
|
cleaner.OnDeleteEvent(folder, "file.txt", false, now.Add(time.Duration(i)*time.Second))
|
|
}
|
|
|
|
// Verify queue length
|
|
if cleaner.GetPendingCleanupCount() != 3 {
|
|
t.Errorf("expected 3 queued folders, got %d", cleaner.GetPendingCleanupCount())
|
|
}
|
|
|
|
// Verify time-sorted order by popping
|
|
for i, expected := range folders {
|
|
folder, _, ok := cleaner.cleanupQueue.Pop()
|
|
if !ok || folder != expected {
|
|
t.Errorf("expected folder %s at index %d, got %s", expected, i, folder)
|
|
}
|
|
}
|
|
|
|
cleaner.Stop()
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_processCleanupQueue_drainsAllOnceTriggered(t *testing.T) {
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}, 0)
|
|
|
|
var deleted []string
|
|
mock := &mockFilerOps{
|
|
countFn: func(_ util.FullPath) (int, error) {
|
|
return 0, nil
|
|
},
|
|
deleteFn: func(path util.FullPath) error {
|
|
deleted = append(deleted, string(path))
|
|
return nil
|
|
},
|
|
}
|
|
|
|
cleaner := &EmptyFolderCleaner{
|
|
filer: mock,
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
bucketPath: "/buckets",
|
|
enabled: true,
|
|
folderCounts: make(map[string]*folderState),
|
|
cleanupQueue: NewCleanupQueue(2, time.Hour),
|
|
maxCountCheck: 1000,
|
|
cacheExpiry: time.Minute,
|
|
processorSleep: time.Second,
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
now := time.Now()
|
|
cleaner.cleanupQueue.Add("/buckets/test/folder1", "i1", now)
|
|
cleaner.cleanupQueue.Add("/buckets/test/folder2", "i2", now.Add(time.Millisecond))
|
|
cleaner.cleanupQueue.Add("/buckets/test/folder3", "i3", now.Add(2*time.Millisecond))
|
|
|
|
cleaner.processCleanupQueue()
|
|
|
|
if got := cleaner.cleanupQueue.Len(); got != 0 {
|
|
t.Fatalf("expected queue to be drained, got len=%d", got)
|
|
}
|
|
if len(deleted) != 3 {
|
|
t.Fatalf("expected 3 deleted folders, got %d", len(deleted))
|
|
}
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_executeCleanup_bucketPolicyDisabledSkips(t *testing.T) {
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}, 0)
|
|
|
|
var deleted []string
|
|
mock := &mockFilerOps{
|
|
countFn: func(_ util.FullPath) (int, error) {
|
|
return 0, nil
|
|
},
|
|
deleteFn: func(path util.FullPath) error {
|
|
deleted = append(deleted, string(path))
|
|
return nil
|
|
},
|
|
attrsFn: func(path util.FullPath) (map[string][]byte, error) {
|
|
if string(path) == "/buckets/test" {
|
|
return map[string][]byte{s3_constants.ExtAllowEmptyFolders: []byte("true")}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
cleaner := &EmptyFolderCleaner{
|
|
filer: mock,
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
bucketPath: "/buckets",
|
|
enabled: true,
|
|
folderCounts: make(map[string]*folderState),
|
|
cleanupQueue: NewCleanupQueue(1000, time.Minute),
|
|
maxCountCheck: 1000,
|
|
cacheExpiry: time.Minute,
|
|
processorSleep: time.Second,
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
folder := "/buckets/test/folder"
|
|
cleaner.executeCleanup(folder, "triggered_item")
|
|
|
|
if len(deleted) != 0 {
|
|
t.Fatalf("expected folder %s to be skipped, got deletions %v", folder, deleted)
|
|
}
|
|
}
|
|
|
|
func TestEmptyFolderCleaner_executeCleanup_directoryMarker(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
isDirKeyObj bool
|
|
expectDeletion bool
|
|
}{
|
|
{
|
|
name: "skips explicit directory marker",
|
|
isDirKeyObj: true,
|
|
expectDeletion: false,
|
|
},
|
|
{
|
|
name: "deletes implicit empty folder",
|
|
isDirKeyObj: false,
|
|
expectDeletion: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}, 0)
|
|
|
|
var deleted []string
|
|
mock := &mockFilerOps{
|
|
countFn: func(_ util.FullPath) (int, error) {
|
|
return 0, nil
|
|
},
|
|
deleteFn: func(path util.FullPath) error {
|
|
deleted = append(deleted, string(path))
|
|
return nil
|
|
},
|
|
isDirKeyObjFn: func(path util.FullPath) (bool, error) {
|
|
return tc.isDirKeyObj, nil
|
|
},
|
|
}
|
|
|
|
cleaner := &EmptyFolderCleaner{
|
|
filer: mock,
|
|
lockRing: lockRing,
|
|
host: "filer1:8888",
|
|
bucketPath: "/buckets",
|
|
enabled: true,
|
|
folderCounts: make(map[string]*folderState),
|
|
cleanupQueue: NewCleanupQueue(1000, time.Minute),
|
|
maxCountCheck: 1000,
|
|
cacheExpiry: time.Minute,
|
|
processorSleep: time.Second,
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
folder := "/buckets/test/folder"
|
|
cleaner.executeCleanup(folder, "triggered_item")
|
|
|
|
if tc.expectDeletion {
|
|
if len(deleted) != 1 || deleted[0] != folder {
|
|
t.Fatalf("expected implicit empty folder %s to be deleted, got deletions %v", folder, deleted)
|
|
}
|
|
} else {
|
|
if len(deleted) != 0 {
|
|
t.Fatalf("expected explicit directory marker %s to be preserved, got deletions %v", folder, deleted)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|