Files
seaweedfs/weed/util/retry_test.go
T
68df7511f6 filer.remote.sync: do not pin the sync offset on completed work (#11569)
* filer.remote.sync: do not pin the sync offset on completed work

* filer.remote.sync: a superseded rename uploads the current entry; typed NotFound for a stamp on a deleted entry

* filer.remote.sync: a superseded rename keeps the old key when it is the only copy and uploads once

* filer.remote.sync: a rename whose content is now remote-only fails the event instead of completing it

* filer.remote.sync: a remote-only rename copies the old object to the destination before deleting it

* filer.remote.sync: the remote-only rename path follows the filer's current entry and verifies the destination object

* filer.remote.sync: an event that described an entry without data is superseded once the filer wrote to it

* filer.remote.sync: a superseded rename does only the work left to do

uploadCurrentEntry met a remote-only current entry with a fixed error, but a
sync plus remote.uncache in the meantime leaves the destination holding the
stamped object; that state is complete, not lost. The remote-only case now
finishes through completeRemoteOnlyRename, which verifies the destination
against the entry stamp and fails only when neither key holds the content.

A current entry whose stamp covers its content was already uploaded by the
superseding event; skip it instead of writing the same bytes again.

* filer.remote.sync: an inherited stamp does not prove the content synced

The stamp-coverage skip in uploadCurrentEntry read LastLocalSyncTsNs as
proof the current content was uploaded, but a rename carries the source
entry's stamp to the destination: a rewrite hidden by that stamp (the case
the fallback upload exists for) carries a LastLocalSyncTsNs at or after its
mtime and would have been skipped. Drop the check; the remote-only path
verifies content at the destination itself through describes.

---------

Co-authored-by: James Sas <james@medable.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-10-03 12:34:52 +08:00

276 lines
7.8 KiB
Go

package util
import (
"context"
"errors"
"fmt"
"io"
"net"
"syscall"
"testing"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestIsTransientError(t *testing.T) {
transient := []error{
// the S3 failure that motivated widening the gate: aws-sdk-go wraps the
// net error in an opaque type, so only the message survives
errors.New(`RequestError: send request failed caused by: Post "https://s3.eu-west-2.amazonaws.com/b/k?uploads=": read tcp 10.0.0.1:53868->1.2.3.4:443: read: connection reset by peer`),
errors.New("rpc error: code = Unavailable desc = transport is closing"),
errors.New("SlowDown: Please reduce your request rate."),
errors.New("InternalError: We encountered an internal error. Please try again."),
fmt.Errorf("send: %w", syscall.ETIMEDOUT),
&net.DNSError{Err: "operation timed out", IsTimeout: true},
io.ErrUnexpectedEOF,
}
for _, err := range transient {
if !IsTransientError(err) {
t.Errorf("expected transient: %v", err)
}
}
permanent := []error{
nil,
errors.New("AccessDenied: Access Denied"),
errors.New("NoSuchBucket: The specified bucket does not exist"),
context.Canceled,
fmt.Errorf("write: %w", context.DeadlineExceeded),
}
for _, err := range permanent {
if IsTransientError(err) {
t.Errorf("expected permanent: %v", err)
}
}
}
// A caller that formats a path into its wrapper must not be able to hand the
// substring matcher a word the client chose: once the chain carries the status
// the server sent, that status is what gets classified.
func TestIsTransientErrorPrefersServerStatus(t *testing.T) {
transient := []error{
fmt.Errorf("list %s: %w", "/buckets/b/logs", status.Error(codes.Unavailable, "filer is restarting")),
fmt.Errorf("list %s: %w", "/buckets/b/filer: no entry is found in filer store", status.Error(codes.Unavailable, "filer is restarting")),
status.Error(codes.ResourceExhausted, "too many requests"),
// the server's own text still counts when the code does not name the condition
status.Error(codes.Internal, "connection reset by peer"),
}
for _, err := range transient {
if !IsTransientError(err) {
t.Errorf("expected transient: %v", err)
}
}
permanent := []error{
fmt.Errorf("list %s: %w", "/buckets/transport/unavailable", status.Error(codes.PermissionDenied, "not allowed")),
fmt.Errorf("all filers failed, last error: %w",
fmt.Errorf("list %s: %w", "/buckets/slowdown/throttling", status.Error(codes.InvalidArgument, "bad prefix"))),
}
for _, err := range permanent {
if IsTransientError(err) {
t.Errorf("expected permanent: %v", err)
}
}
}
func TestIsTransientErrorMessage(t *testing.T) {
transient := []string{
"read tcp 10.0.0.1:8082->10.0.0.1:54848: i/o timeout",
// the same condition relayed by a volume server inside a JSON string
"Upload result: read tcp 10.0.0.1:8082->10.0.0.1:54848: I/O timeout",
"Connection reset by peer",
"dial tcp 10.0.0.1:8888: connect: no route to host",
"rpc error: code = Unavailable desc = the connection is unavailable",
// GCS per-object mutation limit on a hot file; the next attempt after a
// one-second backoff is under it
"googleapi: Error 429: The object bucket/samples.json exceeded the rate limit for object mutation operations (create, update, and delete). Please reduce your request rate. See https://cloud.google.com/storage/docs/gcs429., rateLimitExceeded",
"429 Too Many Requests",
}
for _, msg := range transient {
if !IsTransientErrorMessage(msg) {
t.Errorf("expected transient: %q", msg)
}
}
permanent := []string{
"",
"not found",
"invalid file id",
"chunk size mismatch",
}
for _, msg := range permanent {
if IsTransientErrorMessage(msg) {
t.Errorf("expected permanent: %q", msg)
}
}
}
func TestRetryTransientError(t *testing.T) {
callCount := 0
err := Retry("test", func() error {
callCount++
if callCount < 2 {
return errors.New("read: connection reset by peer")
}
return nil
})
if err != nil {
t.Errorf("expected success, got %v", err)
}
if callCount != 2 {
t.Errorf("expected 2 calls, got %d", callCount)
}
callCount = 0
err = Retry("test", func() error {
callCount++
return errors.New("AccessDenied: Access Denied")
})
if err == nil {
t.Error("expected error")
}
if callCount != 1 {
t.Errorf("expected 1 call for a permanent error, got %d", callCount)
}
}
func TestRetryUntil(t *testing.T) {
// Test case 1: Function succeeds immediately
t.Run("SucceedsImmediately", func(t *testing.T) {
callCount := 0
err := RetryUntil("test", func() error {
callCount++
return nil
}, func(err error) bool {
return false
})
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if callCount != 1 {
t.Errorf("Expected 1 call, got %d", callCount)
}
})
// Test case 2: Function fails with retryable error, then succeeds
t.Run("SucceedsAfterRetry", func(t *testing.T) {
callCount := 0
err := RetryUntil("test", func() error {
callCount++
if callCount < 3 {
return errors.New("retryable error")
}
return nil
}, func(err error) bool {
return err.Error() == "retryable error"
})
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if callCount != 3 {
t.Errorf("Expected 3 calls, got %d", callCount)
}
})
// Test case 3: Function fails with non-retryable error
t.Run("FailsNonRetryable", func(t *testing.T) {
callCount := 0
err := RetryUntil("test", func() error {
callCount++
return errors.New("fatal error")
}, func(err error) bool {
return err.Error() == "retryable error"
})
if err == nil || err.Error() != "fatal error" {
t.Errorf("Expected 'fatal error', got %v", err)
}
if callCount != 1 {
t.Errorf("Expected 1 call, got %d", callCount)
}
})
}
func TestRetryWithBackoff(t *testing.T) {
retryableErr := errors.New("unavailable")
shouldRetry := func(err error) bool { return err == retryableErr }
t.Run("SucceedsAfterRetries", func(t *testing.T) {
callCount := 0
err := RetryWithBackoff(context.Background(), "test", 30*time.Second, shouldRetry, func() error {
callCount++
if callCount < 3 {
return retryableErr
}
return nil
})
if err != nil {
t.Errorf("expected success, got %v", err)
}
if callCount != 3 {
t.Errorf("expected 3 calls, got %d", callCount)
}
})
t.Run("StopsOnNonRetryableError", func(t *testing.T) {
callCount := 0
fatalErr := errors.New("fatal")
err := RetryWithBackoff(context.Background(), "test", 30*time.Second, shouldRetry, func() error {
callCount++
return fatalErr
})
if err != fatalErr {
t.Errorf("expected fatal error, got %v", err)
}
if callCount != 1 {
t.Errorf("expected 1 call, got %d", callCount)
}
})
t.Run("StopsOnContextCancel", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
callCount := 0
start := time.Now()
err := RetryWithBackoff(ctx, "test", 30*time.Second, shouldRetry, func() error {
callCount++
return retryableErr
})
elapsed := time.Since(start)
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("expected DeadlineExceeded, got %v", err)
}
if callCount <= 1 {
t.Errorf("expected multiple calls, got %d", callCount)
}
if elapsed > 5*time.Second {
t.Errorf("took %v, expected to stop near 2s deadline", elapsed)
}
})
t.Run("StopsOnMaxDuration", func(t *testing.T) {
callCount := 0
start := time.Now()
err := RetryWithBackoff(context.Background(), "test", 3*time.Second, shouldRetry, func() error {
callCount++
return retryableErr
})
elapsed := time.Since(start)
if err != retryableErr {
t.Errorf("expected retryable error, got %v", err)
}
if callCount <= 1 {
t.Errorf("expected multiple calls, got %d", callCount)
}
// Should stop around 3s (maxDuration), not run forever
if elapsed > 6*time.Second {
t.Errorf("took %v, expected to stop near 3s maxDuration", elapsed)
}
})
}