mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-09 15:57:47 +02:00
* fix(filer): persist pending chunk deletions across restarts The in-memory FileIdDeletionQueue and DeletionRetryQueue lose every queued-but-unconfirmed deletion when the filer process restarts. Because deletions only enter the pipeline through that queue, a crash between enqueue and the volume confirming the delete leaks the chunk permanently: nothing remembers it. In a multi-filer deployment this was observed as growing collections of orphaned chunks after filer restarts, and — via meta-replay from a peer that still had the entry — orphans being "resurrected" as live references on the recovered filer. This implements the "periodic snapshot with recovery on startup" option noted in the existing DeletionRetryQueue TODO, using the store's KV layer (no new iterator API required across the 15+ store backends): - queueDeletions() is the single entry point that keeps the hot in-memory queue and the durable ledger in sync. - Only terminal outcomes (success / not-found / permanent) remove an id from the ledger; retryable failures keep it, which is the point. - A timer and Shutdown() snapshot the pending set to a single KV key. - On startup, reloadDeletionLedger() re-queues recovered ids after a grace window so the initial peer meta-aggregation settles first. This avoids a new hazard: purging a chunk that a lagging peer is about to re-reference as live data (stale replay turns a stale read into a dangling read otherwise). - Volume deletes are idempotent (not-found == success), so re-deleting after a crash never double-frees. - Kill switch via viper: filer.deleteQueue.persist=false opts out entirely (reload also refuses to recover so a stale ledger never comes back). Tunables: filer.deleteQueue.persistInterval, .recoveryGrace. Adds unit tests covering snapshot+recover, retry-keeps-entry, disabled switch, and zero-value Filer safety (run green under -race). Co-Authored-By: Athena 🏛️ <hermes-agent@local> (custom / Qwen3.8-Flash-Next-ROCmFP4) * filer: harden the deletion ledger - Scope the ledger key by filer address so filers sharing one store do not overwrite each other's pending sets; ledgers written under the old unscoped key are claimed once on startup. - Serialize snapshots on deletionSnapshotLock so an in-flight timer snapshot cannot overwrite a newer shutdown snapshot, and wake the snapshotter on every queue/forget so a queued id persists within milliseconds instead of a full interval. - Merge recovered ids into the pending set immediately on reload; only the queue push waits out the grace window, so an early snapshot rewrites the recovered ids rather than dropping them. - A failed or unparseable ledger read blocks persistence for the run instead of letting snapshots overwrite the unread ledger. - Split the ledger into part keys when it exceeds one 64KB value so stores with a size cap (FoundationDB) do not strand the backlog. - GetReadyItems reports retry-exhausted ids so they are forgotten in the ledger instead of replaying after every restart. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: close the remaining deletion-ledger durability gaps - A manifest referencing a missing part is corruption: surface a wrapped error and block persistence instead of treating the ledger as absent. - Multipart snapshots write generation-scoped part keys and publish the manifest last, so a crash never mixes old and new part contents. - Orphaned parts are tracked in a persisted .stale sidecar and retried. - Legacy/index ledgers are republished under the scoped key before the old keys are removed. - A ledger index lets a filer restart under a new address claim the ledger its previous incarnation left behind. - Expired and permanently-failed retry items only forget the ledger epoch they recorded, so they cannot erase a re-queued id. - A failed startup read no longer disables persistence: every snapshot retries the reload until the store reads again. * filer: tighten ledger claiming, index updates, and retry epochs - touchLedgerIndex verifies its write and retries so a concurrent filer's merge cannot silently drop this key from the index. - Foreign-ledger claims abort on any unreadable source instead of leaving it stranded once the new scoped key exists. - A source that republished during the claim is left in place and its newer ids merge into the claimant's pending set. - AddOrUpdate no longer overwrites the ledger epoch of an in-flight retry item, so its expiry or permanent outcome cannot forget a record that was re-queued after the attempt began. - The recovery grace wait exits on shutdown instead of re-queueing after the filer has stopped. * filer: requeue surviving records, persist claim deltas, guard index writes - A dropped retry item (expired or permanent) whose ledger record was re-enqueued now pushes the id back through the hot queue instead of leaving it pending with nothing scheduled. - Ids merged from a claim source that republished mid-claim are rewritten under our ledger immediately, so they are durable even if the claimant crashes before the next snapshot. - touchLedgerIndex aborts when the index read fails for a real error; only ErrKvNotFound means the index is empty, so a transient failure can no longer wipe peer entries with a one-key write. --------- Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com> Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
333 lines
9.8 KiB
Go
333 lines
9.8 KiB
Go
package filer
|
|
|
|
import (
|
|
"container/heap"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestDeletionRetryQueue_AddAndRetrieve(t *testing.T) {
|
|
queue := NewDeletionRetryQueue()
|
|
|
|
// Add items
|
|
queue.AddOrUpdate("file1", "is read only", 0)
|
|
queue.AddOrUpdate("file2", "connection reset", 0)
|
|
|
|
if queue.Size() != 2 {
|
|
t.Errorf("Expected queue size 2, got %d", queue.Size())
|
|
}
|
|
|
|
// Items not ready yet (initial delay is 5 minutes)
|
|
readyItems, _ := queue.GetReadyItems(10)
|
|
if len(readyItems) != 0 {
|
|
t.Errorf("Expected 0 ready items, got %d", len(readyItems))
|
|
}
|
|
|
|
// Size should remain unchanged
|
|
if queue.Size() != 2 {
|
|
t.Errorf("Expected queue size 2 after checking ready items, got %d", queue.Size())
|
|
}
|
|
}
|
|
|
|
func TestDeletionRetryQueue_ExponentialBackoff(t *testing.T) {
|
|
queue := NewDeletionRetryQueue()
|
|
|
|
// Create an item
|
|
item := &DeletionRetryItem{
|
|
FileId: "test-file",
|
|
RetryCount: 0,
|
|
NextRetryAt: time.Now(),
|
|
LastError: "test error",
|
|
}
|
|
|
|
// Requeue multiple times to test backoff
|
|
delays := []time.Duration{}
|
|
|
|
for i := 0; i < 5; i++ {
|
|
beforeTime := time.Now()
|
|
queue.RequeueForRetry(item, "error")
|
|
|
|
// Calculate expected delay for this retry count
|
|
expectedDelay := InitialRetryDelay * time.Duration(1<<uint(i))
|
|
if expectedDelay > MaxRetryDelay {
|
|
expectedDelay = MaxRetryDelay
|
|
}
|
|
|
|
// Verify NextRetryAt is approximately correct
|
|
actualDelay := item.NextRetryAt.Sub(beforeTime)
|
|
delays = append(delays, actualDelay)
|
|
|
|
// Allow small timing variance
|
|
timeDiff := actualDelay - expectedDelay
|
|
if timeDiff < 0 {
|
|
timeDiff = -timeDiff
|
|
}
|
|
if timeDiff > 100*time.Millisecond {
|
|
t.Errorf("Retry %d: expected delay ~%v, got %v (diff: %v)", i+1, expectedDelay, actualDelay, timeDiff)
|
|
}
|
|
|
|
// Verify retry count incremented
|
|
if item.RetryCount != i+1 {
|
|
t.Errorf("Expected RetryCount %d, got %d", i+1, item.RetryCount)
|
|
}
|
|
|
|
// Reset the heap for the next isolated test iteration
|
|
queue.lock.Lock()
|
|
queue.heap = retryHeap{}
|
|
queue.lock.Unlock()
|
|
}
|
|
|
|
t.Logf("Exponential backoff delays: %v", delays)
|
|
}
|
|
|
|
func TestDeletionRetryQueue_OverflowProtection(t *testing.T) {
|
|
queue := NewDeletionRetryQueue()
|
|
|
|
// Create an item with very high retry count
|
|
item := &DeletionRetryItem{
|
|
FileId: "test-file",
|
|
RetryCount: 60, // High count that would cause overflow without protection
|
|
NextRetryAt: time.Now(),
|
|
LastError: "test error",
|
|
}
|
|
|
|
// Should not panic and should cap at MaxRetryDelay
|
|
queue.RequeueForRetry(item, "error")
|
|
|
|
delay := time.Until(item.NextRetryAt)
|
|
if delay > MaxRetryDelay+time.Second {
|
|
t.Errorf("Delay exceeded MaxRetryDelay: %v > %v", delay, MaxRetryDelay)
|
|
}
|
|
}
|
|
|
|
func TestDeletionRetryQueue_MaxAttemptsReached(t *testing.T) {
|
|
queue := NewDeletionRetryQueue()
|
|
|
|
// Add item
|
|
queue.AddOrUpdate("file1", "error", 0)
|
|
|
|
// Manually set retry count to max
|
|
queue.lock.Lock()
|
|
item, exists := queue.itemIndex["file1"]
|
|
if !exists {
|
|
queue.lock.Unlock()
|
|
t.Fatal("Item not found in queue")
|
|
}
|
|
item.RetryCount = MaxRetryAttempts
|
|
item.NextRetryAt = time.Now().Add(-1 * time.Second) // Ready now
|
|
heap.Fix(&queue.heap, item.heapIndex)
|
|
queue.lock.Unlock()
|
|
|
|
// Try to get ready items - should be returned for the last retry (attempt #10)
|
|
readyItems, _ := queue.GetReadyItems(10)
|
|
if len(readyItems) != 1 {
|
|
t.Fatalf("Expected 1 item for last retry, got %d", len(readyItems))
|
|
}
|
|
|
|
// Requeue it, which will increment its retry count beyond the max
|
|
queue.RequeueForRetry(readyItems[0], "final error")
|
|
|
|
// Manually make it ready again
|
|
queue.lock.Lock()
|
|
item, exists = queue.itemIndex["file1"]
|
|
if !exists {
|
|
queue.lock.Unlock()
|
|
t.Fatal("Item not found in queue after requeue")
|
|
}
|
|
item.NextRetryAt = time.Now().Add(-1 * time.Second)
|
|
heap.Fix(&queue.heap, item.heapIndex)
|
|
queue.lock.Unlock()
|
|
|
|
// Now it should be discarded (retry count is 11, exceeds max of 10)
|
|
readyItems, _ = queue.GetReadyItems(10)
|
|
if len(readyItems) != 0 {
|
|
t.Errorf("Expected 0 items (max attempts exceeded), got %d", len(readyItems))
|
|
}
|
|
|
|
// Should be removed from queue
|
|
if queue.Size() != 0 {
|
|
t.Errorf("Expected queue size 0 after max attempts exceeded, got %d", queue.Size())
|
|
}
|
|
}
|
|
|
|
func TestCalculateBackoff(t *testing.T) {
|
|
testCases := []struct {
|
|
retryCount int
|
|
expectedDelay time.Duration
|
|
description string
|
|
}{
|
|
{1, InitialRetryDelay, "first retry"},
|
|
{2, InitialRetryDelay * 2, "second retry"},
|
|
{3, InitialRetryDelay * 4, "third retry"},
|
|
{4, InitialRetryDelay * 8, "fourth retry"},
|
|
{5, InitialRetryDelay * 16, "fifth retry"},
|
|
{10, MaxRetryDelay, "capped at max delay"},
|
|
{65, MaxRetryDelay, "overflow protection (shift > 63)"},
|
|
{100, MaxRetryDelay, "very high retry count"},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
result := calculateBackoff(tc.retryCount)
|
|
if result != tc.expectedDelay {
|
|
t.Errorf("%s (retry %d): expected %v, got %v",
|
|
tc.description, tc.retryCount, tc.expectedDelay, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsRetryableError(t *testing.T) {
|
|
testCases := []struct {
|
|
error string
|
|
retryable bool
|
|
description string
|
|
}{
|
|
{"volume 123 is read only", true, "read-only volume"},
|
|
{"connection reset by peer", true, "connection reset"},
|
|
{"timeout exceeded", true, "timeout"},
|
|
{"deadline exceeded", true, "deadline exceeded"},
|
|
{"context canceled", true, "context canceled"},
|
|
{"lookup error: volume not found", true, "lookup error"},
|
|
{"connection refused", true, "connection refused"},
|
|
{"too many requests", true, "rate limiting"},
|
|
{"service unavailable", true, "service unavailable"},
|
|
{"i/o timeout", true, "I/O timeout"},
|
|
{"broken pipe", true, "broken pipe"},
|
|
{"not found", false, "not found (not retryable)"},
|
|
{"invalid file id", false, "invalid input (not retryable)"},
|
|
{"", false, "empty error"},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
result := isRetryableError(tc.error)
|
|
if result != tc.retryable {
|
|
t.Errorf("%s: expected retryable=%v, got %v for error: %q",
|
|
tc.description, tc.retryable, result, tc.error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeletionRetryQueue_HeapOrdering(t *testing.T) {
|
|
queue := NewDeletionRetryQueue()
|
|
|
|
now := time.Now()
|
|
|
|
// Add items with different retry times (out of order)
|
|
items := []*DeletionRetryItem{
|
|
{FileId: "file3", RetryCount: 1, NextRetryAt: now.Add(30 * time.Second), LastError: "error3"},
|
|
{FileId: "file1", RetryCount: 1, NextRetryAt: now.Add(10 * time.Second), LastError: "error1"},
|
|
{FileId: "file2", RetryCount: 1, NextRetryAt: now.Add(20 * time.Second), LastError: "error2"},
|
|
}
|
|
|
|
// Add items directly (simulating internal state)
|
|
for _, item := range items {
|
|
queue.lock.Lock()
|
|
queue.itemIndex[item.FileId] = item
|
|
queue.heap = append(queue.heap, item)
|
|
queue.lock.Unlock()
|
|
}
|
|
|
|
// Use container/heap.Init to establish heap property
|
|
queue.lock.Lock()
|
|
heap.Init(&queue.heap)
|
|
queue.lock.Unlock()
|
|
|
|
// Verify heap maintains min-heap property (earliest time at top)
|
|
queue.lock.Lock()
|
|
if queue.heap[0].FileId != "file1" {
|
|
t.Errorf("Expected file1 at heap top (earliest time), got %s", queue.heap[0].FileId)
|
|
}
|
|
queue.lock.Unlock()
|
|
|
|
// Set all items to ready while preserving their relative order
|
|
queue.lock.Lock()
|
|
for _, item := range queue.itemIndex {
|
|
// Shift all times back by 40 seconds to make them ready, but preserve order
|
|
item.NextRetryAt = item.NextRetryAt.Add(-40 * time.Second)
|
|
}
|
|
heap.Init(&queue.heap) // Re-establish heap property after modification
|
|
queue.lock.Unlock()
|
|
|
|
// GetReadyItems should return in NextRetryAt order
|
|
readyItems, _ := queue.GetReadyItems(10)
|
|
expectedOrder := []string{"file1", "file2", "file3"}
|
|
|
|
if len(readyItems) != 3 {
|
|
t.Fatalf("Expected 3 ready items, got %d", len(readyItems))
|
|
}
|
|
|
|
for i, item := range readyItems {
|
|
if item.FileId != expectedOrder[i] {
|
|
t.Errorf("Item %d: expected %s, got %s", i, expectedOrder[i], item.FileId)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeletionRetryQueue_DuplicateFileIds(t *testing.T) {
|
|
queue := NewDeletionRetryQueue()
|
|
|
|
// Add same file ID twice with retryable error - simulates duplicate in batch
|
|
queue.AddOrUpdate("file1", "timeout error", 0)
|
|
|
|
// Verify only one item exists in queue
|
|
if queue.Size() != 1 {
|
|
t.Fatalf("Expected queue size 1 after first add, got %d", queue.Size())
|
|
}
|
|
|
|
// Get initial retry count
|
|
queue.lock.Lock()
|
|
item1, exists := queue.itemIndex["file1"]
|
|
if !exists {
|
|
queue.lock.Unlock()
|
|
t.Fatal("Item not found in queue after first add")
|
|
}
|
|
initialRetryCount := item1.RetryCount
|
|
queue.lock.Unlock()
|
|
|
|
// Add same file ID again - should NOT increment retry count (just update error)
|
|
queue.AddOrUpdate("file1", "timeout error again", 0)
|
|
|
|
// Verify still only one item exists in queue (not duplicated)
|
|
if queue.Size() != 1 {
|
|
t.Errorf("Expected queue size 1 after duplicate add, got %d (duplicates detected)", queue.Size())
|
|
}
|
|
|
|
// Verify retry count did NOT increment (AddOrUpdate only updates error, not count)
|
|
queue.lock.Lock()
|
|
item2, exists := queue.itemIndex["file1"]
|
|
queue.lock.Unlock()
|
|
|
|
if !exists {
|
|
t.Fatal("Item not found in queue after second add")
|
|
}
|
|
if item2.RetryCount != initialRetryCount {
|
|
t.Errorf("Expected RetryCount to stay at %d after duplicate add (should not increment), got %d", initialRetryCount, item2.RetryCount)
|
|
}
|
|
if item2.LastError != "timeout error again" {
|
|
t.Errorf("Expected LastError to be updated to 'timeout error again', got %q", item2.LastError)
|
|
}
|
|
}
|
|
|
|
// AddOrUpdate must not overwrite the ledger epoch on an in-flight item: the
|
|
// worker that popped it reads that field without the queue lock, and its
|
|
// expiry/permanent-forget must only match the record the attempt started with.
|
|
func TestDeletionRetryQueue_InFlightKeepsEpoch(t *testing.T) {
|
|
queue := NewDeletionRetryQueue()
|
|
queue.AddOrUpdate("file1", "timeout", 7)
|
|
|
|
queue.lock.Lock()
|
|
item := queue.itemIndex["file1"]
|
|
item.NextRetryAt = time.Now().Add(-time.Second)
|
|
heap.Init(&queue.heap)
|
|
queue.lock.Unlock()
|
|
|
|
ready, _ := queue.GetReadyItems(1)
|
|
if len(ready) != 1 {
|
|
t.Fatalf("expected the item ready, got %d", len(ready))
|
|
}
|
|
|
|
queue.AddOrUpdate("file1", "newer error", 42)
|
|
if got := ready[0].ledgerEpoch; got != 7 {
|
|
t.Fatalf("in-flight epoch must stay 7, got %d", got)
|
|
}
|
|
}
|