Retry transient uploads and continue backups
All checks were successful
Build / Test and build (push) Successful in 5m53s

This commit is contained in:
2026-08-15 15:32:43 +08:00
parent 977fcefc2f
commit c602698ce9
7 changed files with 175 additions and 28 deletions

View File

@@ -276,12 +276,13 @@ func (a *application) uploadCommand() *cobra.Command {
}
stderr := cmd.ErrOrStderr()
lineOpen := false
summary, err := uploadLocalPath(cmd.Context(), client, args[0], args[1], uploadPathOptions{Excludes: excludes, Progress: func(localPath, remotePath string, done, total int64) {
summary, err := uploadLocalPath(cmd.Context(), client, args[0], args[1], uploadPathOptions{Excludes: excludes, Progress: func(_, remotePath string, done, total int64) {
displayPath := uploadProgressPath(args[1], remotePath)
percent := int64(0)
if total > 0 {
percent = done * 100 / total
}
fmt.Fprintf(stderr, "\r上传 %s -> %s: %d/%d bytes (%d%%)", localPath, remotePath, done, total, percent)
fmt.Fprintf(stderr, "\r上传 %s: %d/%d bytes (%d%%)", displayPath, done, total, percent)
lineOpen = done < total
if !lineOpen {
fmt.Fprintln(stderr)
@@ -292,15 +293,20 @@ func (a *application) uploadCommand() *cobra.Command {
lineOpen = false
}
fmt.Fprintf(stderr, "跳过 %s: %s\n", localPath, reason)
}, Failed: func(_, _ string, uploadErr error) {
if lineOpen {
fmt.Fprintln(stderr)
lineOpen = false
}
fmt.Fprintf(stderr, "失败: %v\n", uploadErr)
}})
if lineOpen {
fmt.Fprintln(stderr)
}
if err != nil {
return err
if summary.Directories > 0 || summary.Files > 0 || summary.Skipped > 0 || summary.Failed > 0 {
fmt.Fprintf(stderr, "完成:成功上传 %d 个文件(%s),处理 %d 个目录,跳过 %d 项,失败 %d 个文件\n", summary.Files, formatBytes(summary.Bytes), summary.Directories, summary.Skipped, summary.Failed)
}
fmt.Fprintf(stderr, "完成:上传 %d 个文件(%s),处理 %d 个目录,跳过 %d 项\n", summary.Files, formatBytes(summary.Bytes), summary.Directories, summary.Skipped)
return nil
return err
}}
cmd.Flags().StringArrayVar(&excludes, "exclude", nil, "排除源目录中的 glob(可重复,支持 **)")
return cmd

View File

@@ -23,16 +23,19 @@ type uploadPathSummary struct {
Files int
Directories int
Skipped int
Failed int
Bytes int64
}
type uploadPathProgress func(localPath, remotePath string, uploaded, total int64)
type uploadPathSkipped func(localPath, reason string)
type uploadPathFailed func(localPath, remotePath string, err error)
type uploadPathOptions struct {
Excludes []string
Progress uploadPathProgress
Skipped uploadPathSkipped
Failed uploadPathFailed
}
type uploadWalkDir func(string, fs.WalkDirFunc) error
@@ -77,6 +80,7 @@ func uploadLocalDirectory(ctx context.Context, client uploadPathClient, root, re
return uploadPathSummary{}, err
}
summary := uploadPathSummary{Directories: 1}
var firstUploadErr error
err := walk(root, func(localPath string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
if err := ctx.Err(); err != nil {
@@ -131,11 +135,23 @@ func uploadLocalDirectory(ctx context.Context, client uploadPathClient, root, re
reportUploadSkip(&summary, options, localPath, "百度开放 API 不支持空文件")
return nil
}
return uploadRegularFile(ctx, client, localPath, remotePath, info, options.Progress, &summary)
if err := uploadRegularFile(ctx, client, localPath, remotePath, info, options.Progress, &summary); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
if firstUploadErr == nil {
firstUploadErr = err
}
reportUploadFailure(&summary, options, localPath, remotePath, err)
}
return nil
})
if err != nil {
return summary, err
}
if summary.Failed > 0 {
return summary, fmt.Errorf("%d files failed to upload; first failure: %w", summary.Failed, firstUploadErr)
}
return summary, nil
}
@@ -146,6 +162,13 @@ func reportUploadSkip(summary *uploadPathSummary, options uploadPathOptions, loc
}
}
func reportUploadFailure(summary *uploadPathSummary, options uploadPathOptions, localPath, remotePath string, err error) {
summary.Failed++
if options.Failed != nil {
options.Failed(localPath, remotePath, err)
}
}
func uploadRegularFile(ctx context.Context, client uploadPathClient, localPath, remotePath string, info fs.FileInfo, progress uploadPathProgress, summary *uploadPathSummary) error {
completeReported := false
if progress != nil && info.Size() > 0 {
@@ -172,6 +195,14 @@ func cleanUploadRemotePath(name string) string {
return pathpkg.Clean("/" + strings.TrimPrefix(name, "/"))
}
func uploadProgressPath(remoteDestination, remotePath string) string {
root := cleanUploadRemotePath(remoteDestination)
if relative := strings.TrimPrefix(remotePath, root+"/"); relative != remotePath {
return relative
}
return pathpkg.Base(remotePath)
}
func normalizeUploadExcludes(patterns []string) ([]string, error) {
result := make([]string, 0, len(patterns))
for _, pattern := range patterns {

View File

@@ -3,6 +3,7 @@ package main
import (
"bytes"
"context"
"errors"
"io/fs"
"os"
"path/filepath"
@@ -16,6 +17,7 @@ import (
type recordingUploadClient struct {
directories []string
files map[string][]byte
failures map[string]error
}
func (c *recordingUploadClient) MkdirAll(_ context.Context, remotePath string) error {
@@ -36,6 +38,9 @@ func (c *recordingUploadClient) UploadFile(_ context.Context, localPath, remoteP
if c.files == nil {
c.files = make(map[string][]byte)
}
if err := c.failures[remotePath]; err != nil {
return baidu.File{}, err
}
c.files[remotePath] = content
if progress != nil {
progress(int64(len(content)), int64(len(content)))
@@ -197,6 +202,35 @@ func TestUploadLocalDirectoryContinuesAfterReadError(t *testing.T) {
}
}
func TestUploadLocalDirectoryContinuesAfterUploadFailure(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "a-fails.txt"), []byte("failed"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "b-succeeds.txt"), []byte("success"), 0o600); err != nil {
t.Fatal(err)
}
client := &recordingUploadClient{failures: map[string]error{
"/backup/mac/a-fails.txt": errors.New("gateway timeout"),
}}
var failed []string
summary, err := uploadLocalPath(context.Background(), client, root, "/backup/mac", uploadPathOptions{Failed: func(localPath, remotePath string, err error) {
failed = append(failed, filepath.Base(localPath)+":"+remotePath+":"+err.Error())
}})
if err == nil || !strings.Contains(err.Error(), "1 files failed to upload") {
t.Fatalf("error = %v", err)
}
if summary.Files != 1 || summary.Failed != 1 || summary.Bytes != 7 {
t.Fatalf("summary = %+v", summary)
}
if len(failed) != 1 || !strings.Contains(failed[0], "a-fails.txt:/backup/mac/a-fails.txt:") || !strings.Contains(failed[0], "gateway timeout") {
t.Fatalf("failed = %q", failed)
}
if !bytes.Equal(client.files["/backup/mac/b-succeeds.txt"], []byte("success")) {
t.Fatalf("b-succeeds.txt was not uploaded: %v", client.files)
}
}
func TestUploadExcludePatternMatching(t *testing.T) {
patterns, err := normalizeUploadExcludes([]string{".Trash/", "Library/Caches/**", "**/*.tmp"})
if err != nil {
@@ -220,3 +254,12 @@ func TestUploadExcludePatternMatching(t *testing.T) {
t.Fatal("invalid exclude pattern was accepted")
}
}
func TestUploadProgressPath(t *testing.T) {
if got := uploadProgressPath("/backup/mac/", "/backup/mac/Library/file.txt"); got != "Library/file.txt" {
t.Fatalf("progress path = %q", got)
}
if got := uploadProgressPath("/backup/file.txt", "/backup/file.txt"); got != "file.txt" {
t.Fatalf("single-file progress path = %q", got)
}
}