| index 6cad1ae8a..81d91b0aa 100644 |
| --- a/internal/dbdoor/restore_test.go |
| +++ b/internal/dbdoor/restore_test.go |
| @@ -5,8 +5,10 @@ import ( |
| "errors" |
| "io" |
| "net/http" |
| + "net/http/httptest" |
| "os" |
| "path/filepath" |
| + "strconv" |
| "strings" |
| "sync" |
| "sync/atomic" |
| @@ -297,3 +299,167 @@ func TestRestoreToStopsWhenCancelledAfterTheDownloads(t *testing.T) { |
| } |
| assertEmptyDir(t, parent) |
| } |
| + |
| +// answer is a canned door response. |
| +func answer(status int, retryAfter string) *http.Response { |
| + rec := httptest.NewRecorder() |
| + if retryAfter != "" { |
| + rec.Header().Set("Retry-After", retryAfter) |
| + } |
| + rec.WriteHeader(status) |
| + io.WriteString(rec, `{"error":"busy"}`) |
| + return rec.Result() |
| +} |
| + |
| +// rangeStart is the offset a request's Range header asks for. |
| +func rangeStart(h http.Header) int64 { |
| + off, _, _ := ParseRange(h.Get("Range")) |
| + return off |
| +} |
| + |
| +// A reopen of the rest of a file that meets a busy door (503, or 429 with |
| +// Retry-After) is retried in place, keeping the bytes already downloaded, |
| +// rather than thrown back to litestream, which would start the file again. |
| +func TestRestoreToRetriesABusyReopenWithoutLosingProgress(t *testing.T) { |
| + defer func(old time.Duration) { dbDownloadBackoff = old }(dbDownloadBackoff) |
| + dbDownloadBackoff = time.Millisecond |
| + src := t.TempDir() |
| + dbdoortest.Replicate(t, src, 2) |
| + door := newTestDoor(t, src) |
| + var mu sync.Mutex |
| + busy := map[string]bool{} |
| + var restarts atomic.Int32 |
| + reached := map[string]int64{} |
| + do := func(ctx context.Context, tail string, h http.Header) (*http.Response, error) { |
| + if !strings.Contains(strings.TrimPrefix(tail, "/ltx/"), "/") { |
| + return door.do(ctx, tail, h) |
| + } |
| + off := rangeStart(h) |
| + key := tail + "@" + strconv.FormatInt(off, 10) |
| + mu.Lock() |
| + if off < reached[tail] { |
| + restarts.Add(1) |
| + } |
| + reached[tail] = max(reached[tail], off) |
| + first := !busy[key] |
| + busy[key] = true |
| + mu.Unlock() |
| + if off > 0 && first { |
| + return answer(http.StatusServiceUnavailable, ""), nil |
| + } |
| + res, err := door.do(ctx, tail, h) |
| + if err == nil { |
| + res.Body = &flakyBody{ReadCloser: res.Body, left: 100} |
| + } |
| + return res, err |
| + } |
| + got, err := RestoreTo(context.Background(), NewClient(do), t.TempDir()) |
| + if err != nil { |
| + t.Fatalf("RestoreTo past busy reopens = %v, want it to retry in place and finish", err) |
| + } |
| + defer os.RemoveAll(got.Dir) |
| + if n := restarts.Load(); n != 0 { |
| + t.Fatalf("%d requests went back to an earlier offset: progress was thrown away", n) |
| + } |
| + if rows := countRows(t, got.Path); rows != 2*dbdoortest.Rows { |
| + t.Fatalf("restored %d rows, want %d", rows, 2*dbdoortest.Rows) |
| + } |
| +} |
| + |
| +// A 429's Retry-After is honoured before the next attempt. |
| +func TestRestoreToHonoursRetryAfter(t *testing.T) { |
| + defer func(old time.Duration) { dbDownloadBackoff = old }(dbDownloadBackoff) |
| + dbDownloadBackoff = time.Millisecond |
| + src := t.TempDir() |
| + dbdoortest.Replicate(t, src, 1) |
| + door := newTestDoor(t, src) |
| + var mu sync.Mutex |
| + var refusedAt, nextAt time.Time |
| + do := func(ctx context.Context, tail string, h http.Header) (*http.Response, error) { |
| + if !strings.Contains(strings.TrimPrefix(tail, "/ltx/"), "/") { |
| + return door.do(ctx, tail, h) |
| + } |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if rangeStart(h) > 0 && refusedAt.IsZero() { |
| + refusedAt = time.Now() |
| + return answer(http.StatusTooManyRequests, "1"), nil |
| + } |
| + if !refusedAt.IsZero() && nextAt.IsZero() { |
| + nextAt = time.Now() |
| + } |
| + res, err := door.do(ctx, tail, h) |
| + if err == nil && rangeStart(h) == 0 { |
| + res.Body = &flakyBody{ReadCloser: res.Body, left: 100} |
| + } |
| + return res, err |
| + } |
| + got, err := RestoreTo(context.Background(), NewClient(do), t.TempDir()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer os.RemoveAll(got.Dir) |
| + if refusedAt.IsZero() || nextAt.Sub(refusedAt) < 900*time.Millisecond { |
| + t.Fatalf("retried %v after a 429 with Retry-After: 1", nextAt.Sub(refusedAt)) |
| + } |
| +} |
| + |
| +// The wait between attempts ends the moment the person stops. |
| +func TestRestoreToBackoffIsCancellable(t *testing.T) { |
| + defer func(old time.Duration) { dbDownloadBackoff = old }(dbDownloadBackoff) |
| + dbDownloadBackoff = time.Hour |
| + src := t.TempDir() |
| + dbdoortest.Replicate(t, src, 1) |
| + door := newTestDoor(t, src) |
| + do := func(ctx context.Context, tail string, h http.Header) (*http.Response, error) { |
| + if strings.Contains(strings.TrimPrefix(tail, "/ltx/"), "/") && rangeStart(h) > 0 { |
| + return answer(http.StatusServiceUnavailable, ""), nil |
| + } |
| + res, err := door.do(ctx, tail, h) |
| + if err == nil && strings.Contains(strings.TrimPrefix(tail, "/ltx/"), "/") { |
| + res.Body = &flakyBody{ReadCloser: res.Body, left: 100} |
| + } |
| + return res, err |
| + } |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + time.AfterFunc(200*time.Millisecond, cancel) |
| + start := time.Now() |
| + parent := t.TempDir() |
| + if _, err := RestoreTo(ctx, NewClient(do), parent); !errors.Is(err, context.Canceled) { |
| + t.Fatalf("RestoreTo cancelled during a backoff = %v, want context.Canceled", err) |
| + } |
| + if d := time.Since(start); d > 5*time.Second { |
| + t.Fatalf("the stop took %v to land", d) |
| + } |
| + assertEmptyDir(t, parent) |
| +} |
| + |
| +// A door that stays busy is given up on: only attempts that deliver no |
| +// byte count, and there are a bounded number of them. |
| +func TestRestoreToGivesUpOnAPermanentlyBusyDoor(t *testing.T) { |
| + defer func(old time.Duration) { dbDownloadBackoff = old }(dbDownloadBackoff) |
| + dbDownloadBackoff = time.Millisecond |
| + src := t.TempDir() |
| + dbdoortest.Replicate(t, src, 1) |
| + door := newTestDoor(t, src) |
| + var reopens atomic.Int32 |
| + do := func(ctx context.Context, tail string, h http.Header) (*http.Response, error) { |
| + if strings.Contains(strings.TrimPrefix(tail, "/ltx/"), "/") && rangeStart(h) > 0 { |
| + reopens.Add(1) |
| + return answer(http.StatusServiceUnavailable, ""), nil |
| + } |
| + res, err := door.do(ctx, tail, h) |
| + if err == nil && strings.Contains(strings.TrimPrefix(tail, "/ltx/"), "/") { |
| + res.Body = &flakyBody{ReadCloser: res.Body, left: 100} |
| + } |
| + return res, err |
| + } |
| + parent := t.TempDir() |
| + if _, err := RestoreTo(context.Background(), NewClient(do), parent); err == nil { |
| + t.Fatal("RestoreTo against a door that never recovers succeeded") |
| + } |
| + if n := reopens.Load(); n == 0 || n > 100 { |
| + t.Fatalf("%d reopens against a permanently busy door", n) |
| + } |
| + assertEmptyDir(t, parent) |
| +} |