From ff17adb98c55c8787a66e30c893841cdf3e04751 Mon Sep 17 00:00:00 2001 From: Novum <284059832+NovumOrbis@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:31:53 +0800 Subject: [PATCH 1/2] fix(quark): recover transient upload control errors Add bounded, stage-aware retries for known Quark upload control-plane transients. Defer finish visibility recovery until the retry budget is exhausted, verify only the exact upload FID through a raw context-aware /file/sort probe, fail closed when FID is unavailable, cap visibility pagination, and cover replay/cancellation safety with regression tests. --- drivers/quark_uc/driver.go | 13 +- drivers/quark_uc/upload_reliability.go | 280 +++++++++++ .../upload_reliability_hardening_test.go | 296 ++++++++++++ drivers/quark_uc/upload_reliability_test.go | 451 ++++++++++++++++++ 4 files changed, 1032 insertions(+), 8 deletions(-) create mode 100644 drivers/quark_uc/upload_reliability.go create mode 100644 drivers/quark_uc/upload_reliability_hardening_test.go create mode 100644 drivers/quark_uc/upload_reliability_test.go diff --git a/drivers/quark_uc/driver.go b/drivers/quark_uc/driver.go index 264150dc920..505f1ea57e3 100644 --- a/drivers/quark_uc/driver.go +++ b/drivers/quark_uc/driver.go @@ -1,7 +1,6 @@ package quark import ( - "bytes" "context" "encoding/hex" "hash" @@ -185,13 +184,13 @@ func (d *QuarkOrUC) Put(ctx context.Context, dstDir model.Obj, stream model.File } } // pre - pre, err := d.upPre(stream, dstDir.GetID()) + pre, err := d.upPreReliable(ctx, stream, dstDir.GetID()) if err != nil { return err } log.Debugln("hash: ", md5Str, sha1Str) // hash - finish, err := d.upHash(md5Str, sha1Str, pre.Data.TaskId) + finish, err := d.upHashReliable(ctx, md5Str, sha1Str, pre.Data.TaskId) if err != nil { return err } @@ -222,9 +221,7 @@ func (d *QuarkOrUC) Put(ctx context.Context, dstDir model.Obj, stream model.File } left -= int64(n) log.Debugf("left: %d", left) - reader := driver.NewLimitedUploadStream(ctx, bytes.NewReader(part)) - m, err := d.upPart(ctx, pre, stream.GetMimetype(), partNumber, reader) - //m, err := driver.UpPart(pre, file.GetMIMEType(), partNumber, bytes, account, md5Str, sha1Str) + m, err := d.upPartReliable(ctx, pre, stream.GetMimetype(), partNumber, part) if err != nil { return err } @@ -235,11 +232,11 @@ func (d *QuarkOrUC) Put(ctx context.Context, dstDir model.Obj, stream model.File partNumber++ up(100 * float64(total-left) / float64(total)) } - err = d.upCommit(pre, md5s) + err = d.upCommitReliable(ctx, pre, md5s) if err != nil { return err } - return d.upFinish(pre) + return d.upFinishReliable(ctx, pre, dstDir.GetID(), stream.GetName(), stream.GetSize()) } var _ driver.Driver = (*QuarkOrUC)(nil) diff --git a/drivers/quark_uc/upload_reliability.go b/drivers/quark_uc/upload_reliability.go new file mode 100644 index 00000000000..513ff06c676 --- /dev/null +++ b/drivers/quark_uc/upload_reliability.go @@ -0,0 +1,280 @@ +package quark + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/model" + "github.com/go-resty/resty/v2" + log "github.com/sirupsen/logrus" +) + +const ( + uploadControlMaxAttempts = 3 + uploadControlInitialBackoff = time.Second + uploadControlMaxBackoff = 2 * time.Second + uploadFinishVisibilityAttempts = 4 + uploadFinishVisibilityInitialBackoff = 250 * time.Millisecond + uploadFinishVisibilityMaxBackoff = time.Second + uploadFinishVisibilityMaxPages = 1000 +) + +func wrapQuarkUploadStage(stage string, err error) error { + if err == nil { + return nil + } + return fmt.Errorf("quark upload stage %s: %w", stage, err) +} + +func hasQuarkUploadErrorTokenPrefix(msg, token string) bool { + if msg == token { + return true + } + if !strings.HasPrefix(msg, token) || len(msg) == len(token) { + return false + } + switch msg[len(token)] { + case ' ', ',', ':', '\t': + return true + default: + return false + } +} + +func isRetryableQuarkUploadControlError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(strings.TrimSpace(err.Error())) + if hasQuarkUploadErrorTokenPrefix(msg, "complete_upload_lock_timeout") { + return true + } + return hasQuarkUploadErrorTokenPrefix(msg, "inner error") && strings.Contains(msg, "requestid") +} + +func waitWithContext(ctx context.Context, delay time.Duration) error { + if delay <= 0 { + return ctx.Err() + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func retryQuarkUploadControl[T any](ctx context.Context, stage string, fn func() (T, error)) (T, error) { + var zero T + backoff := uploadControlInitialBackoff + for attempt := 1; attempt <= uploadControlMaxAttempts; attempt++ { + if err := ctx.Err(); err != nil { + return zero, wrapQuarkUploadStage(stage, err) + } + + value, err := fn() + if err == nil { + return value, nil + } + if !isRetryableQuarkUploadControlError(err) { + return zero, wrapQuarkUploadStage(stage, err) + } + if attempt == uploadControlMaxAttempts { + return zero, wrapQuarkUploadStage(stage, fmt.Errorf("transient provider error after %d attempts: %w", attempt, err)) + } + + log.Warnf("quark upload stage=%s transient provider error attempt=%d/%d: %v; retrying", stage, attempt, uploadControlMaxAttempts, err) + if err := waitWithContext(ctx, backoff); err != nil { + return zero, wrapQuarkUploadStage(stage, err) + } + backoff *= 2 + if backoff > uploadControlMaxBackoff { + backoff = uploadControlMaxBackoff + } + } + return zero, wrapQuarkUploadStage(stage, fmt.Errorf("retry loop exhausted unexpectedly")) +} + +// upPreReliable intentionally does not add a driver-layer retry: replaying +// /file/upload/pre can allocate a second upload task/FID. It only adds +// cancellation and stage attribution. +func (d *QuarkOrUC) upPreReliable(ctx context.Context, file model.FileStreamer, parentID string) (UpPreResp, error) { + if err := ctx.Err(); err != nil { + return UpPreResp{}, wrapQuarkUploadStage("pre", err) + } + pre, err := d.upPre(file, parentID) + if err != nil { + return pre, wrapQuarkUploadStage("pre", err) + } + return pre, nil +} + +func (d *QuarkOrUC) upHashReliable(ctx context.Context, md5, sha1, taskID string) (bool, error) { + return retryQuarkUploadControl(ctx, "hash", func() (bool, error) { + return d.upHash(md5, sha1, taskID) + }) +} + +func (d *QuarkOrUC) upPartReliable(ctx context.Context, pre UpPreResp, mimeType string, partNumber int, part []byte) (string, error) { + stage := fmt.Sprintf("part[%d]", partNumber) + return retryQuarkUploadControl(ctx, stage, func() (string, error) { + // Rebuild the reader for every attempt. Retries are admitted only for the + // Quark /file/upload/auth control-plane transient, before this OSS body is sent. + reader := driver.NewLimitedUploadStream(ctx, bytes.NewReader(part)) + return d.upPart(ctx, pre, mimeType, partNumber, io.Reader(reader)) + }) +} + +func (d *QuarkOrUC) upCommitReliable(ctx context.Context, pre UpPreResp, md5s []string) error { + // upCommit asks Quark /file/upload/auth before issuing CompleteMultipartUpload. + // The driver-level retry predicate accepts only the raw Quark control-plane + // transient messages; OSS-formatted errors are not retried by this wrapper. + _, err := retryQuarkUploadControl(ctx, "commit", func() (struct{}, error) { + return struct{}{}, d.upCommit(pre, md5s) + }) + return err +} + +func (d *QuarkOrUC) upFinishReliable(ctx context.Context, pre UpPreResp, parentID, fileName string, fileSize int64) error { + backoff := uploadControlInitialBackoff + for attempt := 1; attempt <= uploadControlMaxAttempts; attempt++ { + if err := ctx.Err(); err != nil { + return wrapQuarkUploadStage("finish", err) + } + + err := d.upFinish(pre) + if err == nil { + return nil + } + if !isRetryableQuarkUploadControlError(err) { + return wrapQuarkUploadStage("finish", err) + } + + if attempt < uploadControlMaxAttempts { + log.Warnf("quark upload stage=finish transient provider error attempt=%d/%d: %v; retrying finish", attempt, uploadControlMaxAttempts, err) + if err := waitWithContext(ctx, backoff); err != nil { + return wrapQuarkUploadStage("finish", err) + } + backoff *= 2 + if backoff > uploadControlMaxBackoff { + backoff = uploadControlMaxBackoff + } + continue + } + + // Only use directory visibility as a last-resort tiebreaker after the + // bounded finish retry budget is exhausted. A pre-allocated FID may exist + // before finish, so checking it earlier could turn an uncertain finish + // into a false success and let op.Put discard the old overwrite fallback. + if pre.Data.Fid != "" { + visible, visibilityErr := d.waitUploadedObjectVisible(ctx, pre, parentID) + if visible { + log.Warnf("quark upload stage=finish remained transient after %d attempts but uploaded object fid=%s is visible; treating as success: %v", uploadControlMaxAttempts, pre.Data.Fid, err) + return nil + } + if visibilityErr != nil { + log.Warnf("quark upload stage=finish visibility verification failed after retry budget: %v", visibilityErr) + } + } else { + // fileName/fileSize are diagnostic only. They must never be used as an + // identity fallback because an older object may share both values. + log.Warnf("quark upload stage=finish cannot verify uploaded object name=%q size=%d because pre response has no fid; failing closed after retry budget: %v", fileName, fileSize, err) + } + return wrapQuarkUploadStage("finish", fmt.Errorf("transient provider error after %d attempts: %w", attempt, err)) + } + return wrapQuarkUploadStage("finish", fmt.Errorf("retry loop exhausted unexpectedly")) +} + +func (d *QuarkOrUC) waitUploadedObjectVisible(ctx context.Context, pre UpPreResp, parentID string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if pre.Data.Fid == "" { + return false, nil + } + + backoff := uploadFinishVisibilityInitialBackoff + var lastErr error + for attempt := 0; attempt < uploadFinishVisibilityAttempts; attempt++ { + visible, err := d.findUploadedFileByFID(ctx, parentID, pre.Data.Fid) + if err != nil { + lastErr = err + } else { + lastErr = nil + if visible { + return true, nil + } + } + + if attempt == uploadFinishVisibilityAttempts-1 { + break + } + if err := waitWithContext(ctx, backoff); err != nil { + return false, err + } + backoff *= 2 + if backoff > uploadFinishVisibilityMaxBackoff { + backoff = uploadFinishVisibilityMaxBackoff + } + } + return false, lastErr +} + +func (d *QuarkOrUC) findUploadedFileByFID(ctx context.Context, parentID, fid string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if fid == "" { + return false, nil + } + + const pageSize = 100 + query := map[string]string{ + "pdir_fid": parentID, + "_size": strconv.Itoa(pageSize), + "_fetch_total": "1", + "fetch_all_file": "1", + "fetch_risk_file_name": "1", + } + for page := 1; ; page++ { + if err := ctx.Err(); err != nil { + return false, err + } + if page > uploadFinishVisibilityMaxPages { + return false, fmt.Errorf("quark upload visibility listing exceeded %d pages for parent %s", uploadFinishVisibilityMaxPages, parentID) + } + + query["_page"] = strconv.Itoa(page) + var resp SortResp + _, err := d.request("/file/sort", http.MethodGet, func(req *resty.Request) { + req.SetContext(ctx).SetQueryParams(query) + }, &resp) + if err != nil { + return false, err + } + + for i := range resp.Data.List { + file := &resp.Data.List[i] + if file.File && file.Fid == fid { + return true, nil + } + } + + if resp.Metadata.Total > 0 { + if page*pageSize >= resp.Metadata.Total { + return false, nil + } + } else if len(resp.Data.List) < pageSize { + return false, nil + } + } +} diff --git a/drivers/quark_uc/upload_reliability_hardening_test.go b/drivers/quark_uc/upload_reliability_hardening_test.go new file mode 100644 index 00000000000..76a8ecb8721 --- /dev/null +++ b/drivers/quark_uc/upload_reliability_hardening_test.go @@ -0,0 +1,296 @@ +package quark + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/alist-org/alist/v3/drivers/base" + "github.com/go-resty/resty/v2" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func ossResponse(req *http.Request, status int, body string, headers http.Header) *http.Response { + if headers == nil { + headers = make(http.Header) + } + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Header: headers, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + } +} + +func uploadTestPre() UpPreResp { + var pre UpPreResp + pre.Data.TaskId = "task-test" + pre.Data.UploadId = "upload-test" + pre.Data.ObjKey = "object-test" + pre.Data.UploadUrl = "https://oss.test" + pre.Data.Bucket = "bucket" + pre.Data.AuthInfo = "auth-info" + return pre +} + +func TestRetryClassifierRejectsTokenExtensions(t *testing.T) { + for _, msg := range []string{ + "complete_upload_lock_timeout_but_other", + "inner errors, requestId abc", + } { + if isRetryableQuarkUploadControlError(errors.New(msg)) { + t.Fatalf("token extension must not be retryable: %q", msg) + } + } +} + +func TestFindUploadedFileByFIDStopsAtPageCap(t *testing.T) { + calls := 0 + fullPage := make([]map[string]any, 0, 100) + for i := 0; i < 100; i++ { + fullPage = append(fullPage, map[string]any{ + "fid": "other", + "file_name": "other", + "file": true, + "size": 1, + }) + } + + srv := httptestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/1/clouddrive/file/sort" { + http.NotFound(w, r) + return + } + calls++ + writeJSON(w, http.StatusOK, map[string]any{ + "status": 200, + "code": 0, + "data": map[string]any{"list": fullPage}, + "metadata": map[string]any{ + "_size": 100, "_page": calls, "_count": 100, "_total": 0, + }, + }) + })) + + d := newTestDriver(srv.URL) + visible, err := d.findUploadedFileByFID(context.Background(), "parent", "target") + if visible { + t.Fatal("unexpected visible target") + } + if err == nil || !strings.Contains(err.Error(), "exceeded") { + t.Fatalf("err=%v, want page-cap error", err) + } + if calls != uploadFinishVisibilityMaxPages { + t.Fatalf("calls=%d, want %d", calls, uploadFinishVisibilityMaxPages) + } +} + +func TestFindUploadedFileByFIDCancelsInFlightRequest(t *testing.T) { + started := make(chan struct{}, 1) + srv := httptestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/1/clouddrive/file/sort" { + http.NotFound(w, r) + return + } + started <- struct{}{} + <-r.Context().Done() + })) + + d := newTestDriver(srv.URL) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + _, err := d.findUploadedFileByFID(ctx, "parent", "target") + done <- err + }() + + select { + case <-started: + cancel() + case <-time.After(2 * time.Second): + t.Fatal("visibility request did not reach server") + } + + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("err=%v, want context.Canceled", err) + } + case <-time.After(2 * time.Second): + t.Fatal("in-flight visibility request did not cancel") + } +} + +func TestUpPartReliableRetriesAuthBeforeSendingOSSOnce(t *testing.T) { + authCalls := 0 + srv := httptestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/1/clouddrive/file/upload/auth" { + http.NotFound(w, r) + return + } + authCalls++ + if authCalls == 1 { + writeJSON(w, http.StatusInternalServerError, Resp{Status: 500, Code: 500, Message: "inner error, requestId auth-1"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": 200, + "code": 0, + "data": map[string]any{"auth_key": "key"}, + }) + })) + + oldClient := base.RestyClient + defer func() { base.RestyClient = oldClient }() + ossCalls := 0 + var gotBody string + base.RestyClient = resty.NewWithClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + ossCalls++ + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + gotBody = string(body) + headers := make(http.Header) + headers.Set("Etag", "etag-1") + return ossResponse(req, http.StatusOK, "", headers), nil + })}) + + d := newTestDriver(srv.URL) + part := []byte("full-part-body") + etag, err := d.upPartReliable(context.Background(), uploadTestPre(), "application/octet-stream", 1, part) + if err != nil { + t.Fatalf("upPartReliable: %v", err) + } + if authCalls != 2 || ossCalls != 1 { + t.Fatalf("authCalls=%d ossCalls=%d, want 2/1", authCalls, ossCalls) + } + if gotBody != string(part) { + t.Fatalf("OSS body=%q, want %q", gotBody, string(part)) + } + if etag != "etag-1" { + t.Fatalf("etag=%q, want etag-1", etag) + } +} + +func TestUpPartReliableDoesNotRetryOSSError(t *testing.T) { + authCalls := 0 + srv := httptestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/1/clouddrive/file/upload/auth" { + http.NotFound(w, r) + return + } + authCalls++ + writeJSON(w, http.StatusOK, map[string]any{ + "status": 200, + "code": 0, + "data": map[string]any{"auth_key": "key"}, + }) + })) + + oldClient := base.RestyClient + defer func() { base.RestyClient = oldClient }() + ossCalls := 0 + base.RestyClient = resty.NewWithClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + ossCalls++ + return ossResponse(req, http.StatusInternalServerError, "inner error, requestId oss-1", nil), nil + })}) + + d := newTestDriver(srv.URL) + _, err := d.upPartReliable(context.Background(), uploadTestPre(), "application/octet-stream", 1, []byte("part")) + if err == nil || !strings.Contains(err.Error(), "up status: 500") { + t.Fatalf("err=%v, want OSS status error", err) + } + if authCalls != 1 || ossCalls != 1 { + t.Fatalf("authCalls=%d ossCalls=%d, want 1/1", authCalls, ossCalls) + } +} + +func TestUpCommitReliableRetriesAuthBeforeSendingOSSOnce(t *testing.T) { + authCalls := 0 + srv := httptestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/1/clouddrive/file/upload/auth" { + http.NotFound(w, r) + return + } + authCalls++ + if authCalls == 1 { + writeJSON(w, http.StatusInternalServerError, Resp{Status: 500, Code: 500, Message: "complete_upload_lock_timeout"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": 200, + "code": 0, + "data": map[string]any{"auth_key": "key"}, + }) + })) + + oldClient := base.RestyClient + defer func() { base.RestyClient = oldClient }() + ossCalls := 0 + base.RestyClient = resty.NewWithClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + ossCalls++ + return ossResponse(req, http.StatusOK, "", nil), nil + })}) + + d := newTestDriver(srv.URL) + if err := d.upCommitReliable(context.Background(), uploadTestPre(), []string{"etag-1"}); err != nil { + t.Fatalf("upCommitReliable: %v", err) + } + if authCalls != 2 || ossCalls != 1 { + t.Fatalf("authCalls=%d ossCalls=%d, want 2/1", authCalls, ossCalls) + } +} + +func TestUpCommitReliableDoesNotRetryOSSError(t *testing.T) { + authCalls := 0 + srv := httptestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/1/clouddrive/file/upload/auth" { + http.NotFound(w, r) + return + } + authCalls++ + writeJSON(w, http.StatusOK, map[string]any{ + "status": 200, + "code": 0, + "data": map[string]any{"auth_key": "key"}, + }) + })) + + oldClient := base.RestyClient + defer func() { base.RestyClient = oldClient }() + ossCalls := 0 + base.RestyClient = resty.NewWithClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + ossCalls++ + return ossResponse(req, http.StatusInternalServerError, "inner error, requestId oss-commit", nil), nil + })}) + + d := newTestDriver(srv.URL) + err := d.upCommitReliable(context.Background(), uploadTestPre(), []string{"etag-1"}) + if err == nil || !strings.Contains(err.Error(), "up status: 500") { + t.Fatalf("err=%v, want OSS status error", err) + } + if authCalls != 1 || ossCalls != 1 { + t.Fatalf("authCalls=%d ossCalls=%d, want 1/1", authCalls, ossCalls) + } +} + +func httptestServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return srv +} diff --git a/drivers/quark_uc/upload_reliability_test.go b/drivers/quark_uc/upload_reliability_test.go new file mode 100644 index 00000000000..6be83b76723 --- /dev/null +++ b/drivers/quark_uc/upload_reliability_test.go @@ -0,0 +1,451 @@ +package quark + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestIsRetryableQuarkUploadControlError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "lock timeout", err: errors.New("complete_upload_lock_timeout"), want: true}, + {name: "lock timeout with detail", err: errors.New("complete_upload_lock_timeout, requestId abc"), want: true}, + {name: "inner error request id", err: errors.New("inner error, requestId 396ius-abc"), want: true}, + {name: "generic inner error", err: errors.New("inner error"), want: false}, + {name: "oss formatted inner error must not replay", err: errors.New("up status: 500, error: inner error, requestId abc"), want: false}, + {name: "same-name conflict", err: errors.New("file is doloading[同名冲突]"), want: false}, + {name: "transport timeout", err: errors.New("context deadline exceeded"), want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isRetryableQuarkUploadControlError(tt.err); got != tt.want { + t.Fatalf("isRetryableQuarkUploadControlError(%q) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + +func TestRetryQuarkUploadControlRetriesTransientThenSucceeds(t *testing.T) { + calls := 0 + got, err := retryQuarkUploadControl(context.Background(), "hash", func() (int, error) { + calls++ + if calls == 1 { + return 0, errors.New("inner error, requestId test-1") + } + return 42, nil + }) + if err != nil { + t.Fatalf("retryQuarkUploadControl: %v", err) + } + if got != 42 || calls != 2 { + t.Fatalf("got=%d calls=%d, want 42/2", got, calls) + } +} + +func TestRetryQuarkUploadControlDoesNotRetryNonTransient(t *testing.T) { + calls := 0 + _, err := retryQuarkUploadControl(context.Background(), "hash", func() (int, error) { + calls++ + return 0, errors.New("permission denied") + }) + if err == nil { + t.Fatal("want error") + } + if calls != 1 { + t.Fatalf("calls=%d, want 1", calls) + } + if !strings.Contains(err.Error(), "stage hash") || !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("unexpected staged error: %v", err) + } +} + +func TestRetryQuarkUploadControlHonorsCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + calls := 0 + _, err := retryQuarkUploadControl(ctx, "hash", func() (int, error) { + calls++ + return 0, nil + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error=%v, want context.Canceled", err) + } + if calls != 0 { + t.Fatalf("calls=%d, want 0", calls) + } +} + +func TestUpHashReliableRetriesProviderTransient(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/1/clouddrive/file/update/hash" { + http.NotFound(w, r) + return + } + calls++ + if calls == 1 { + writeJSON(w, http.StatusInternalServerError, Resp{Status: 500, Code: 500, Message: "inner error, requestId hash-1"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": 200, + "code": 0, + "data": map[string]any{"finish": false}, + }) + })) + defer srv.Close() + + d := newTestDriver(srv.URL) + finish, err := d.upHashReliable(context.Background(), "md5", "sha1", "task-1") + if err != nil { + t.Fatalf("upHashReliable: %v", err) + } + if finish { + t.Fatal("finish=true, want false") + } + if calls != 2 { + t.Fatalf("calls=%d, want 2", calls) + } +} + +func TestUpFinishReliableTreatsVisibleObjectAsSuccessAfterRetryBudget(t *testing.T) { + finishCalls := 0 + listCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/1/clouddrive/file/upload/finish": + finishCalls++ + writeJSON(w, http.StatusInternalServerError, Resp{Status: 500, Code: 500, Message: "complete_upload_lock_timeout"}) + case r.Method == http.MethodGet && r.URL.Path == "/1/clouddrive/file/sort": + listCalls++ + writeJSON(w, http.StatusOK, map[string]any{ + "status": 200, + "code": 0, + "data": map[string]any{"list": []map[string]any{{ + "fid": "fid-857", "file_name": "857.bucket.2", "file": true, "size": 123, + }}}, + "metadata": map[string]any{"_size": 100, "_page": 1, "_count": 1, "_total": 1}, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + d := newTestDriver(srv.URL) + var pre UpPreResp + pre.Data.TaskId = "task-857" + pre.Data.ObjKey = "obj-857" + pre.Data.Fid = "fid-857" + if err := d.upFinishReliable(context.Background(), pre, "parent", "857.bucket.2", 123); err != nil { + t.Fatalf("upFinishReliable: %v", err) + } + if finishCalls != uploadControlMaxAttempts || listCalls != 1 { + t.Fatalf("finishCalls=%d listCalls=%d, want %d/1", finishCalls, listCalls, uploadControlMaxAttempts) + } +} + +func TestWaitUploadedObjectVisibleRequiresMatchingKnownFid(t *testing.T) { + listCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/1/clouddrive/file/sort" { + http.NotFound(w, r) + return + } + listCalls++ + writeJSON(w, http.StatusOK, map[string]any{ + "status": 200, + "code": 0, + "data": map[string]any{"list": []map[string]any{{ + "fid": "stale-old-fid", "file_name": "857.bucket.2", "file": true, "size": 123, + }}}, + "metadata": map[string]any{"_size": 100, "_page": 1, "_count": 1, "_total": 1}, + }) + })) + defer srv.Close() + + d := newTestDriver(srv.URL) + var pre UpPreResp + pre.Data.Fid = "new-upload-fid" + visible, err := d.waitUploadedObjectVisible(context.Background(), pre, "parent") + if err != nil { + t.Fatalf("waitUploadedObjectVisible: %v", err) + } + if visible { + t.Fatal("stale same-name same-size object with a different fid must not verify the current upload") + } + if listCalls != uploadFinishVisibilityAttempts { + t.Fatalf("listCalls=%d, want %d", listCalls, uploadFinishVisibilityAttempts) + } +} + +func TestWaitUploadedObjectVisibleMissingFidFailsClosed(t *testing.T) { + listCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + listCalls++ + writeJSON(w, http.StatusOK, map[string]any{ + "status": 200, + "code": 0, + "data": map[string]any{"list": []map[string]any{{ + "fid": "stale-old-fid", "file_name": "857.bucket.2", "file": true, "size": 123, + }}}, + "metadata": map[string]any{"_size": 100, "_page": 1, "_count": 1, "_total": 1}, + }) + })) + defer srv.Close() + + d := newTestDriver(srv.URL) + visible, err := d.waitUploadedObjectVisible(context.Background(), UpPreResp{}, "parent") + if err != nil { + t.Fatalf("waitUploadedObjectVisible: %v", err) + } + if visible { + t.Fatal("missing upload fid must never verify by name and size") + } + if listCalls != 0 { + t.Fatalf("listCalls=%d, want 0 when fid is unavailable", listCalls) + } +} + +func TestFindUploadedFileByFIDPaginatesRawListing(t *testing.T) { + listCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/1/clouddrive/file/sort" { + http.NotFound(w, r) + return + } + listCalls++ + q := r.URL.Query() + if q.Get("pdir_fid") != "parent" || q.Get("_size") != "100" || q.Get("_fetch_total") != "1" || q.Get("fetch_all_file") != "1" || q.Get("fetch_risk_file_name") != "1" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + switch q.Get("_page") { + case "1": + writeJSON(w, http.StatusOK, map[string]any{ + "status": 200, + "code": 0, + "data": map[string]any{"list": []map[string]any{{ + "fid": "other-fid", "file_name": "other.bin", "file": true, "size": 1, + }}}, + "metadata": map[string]any{"_size": 100, "_page": 1, "_count": 1, "_total": 101}, + }) + case "2": + writeJSON(w, http.StatusOK, map[string]any{ + "status": 200, + "code": 0, + "data": map[string]any{"list": []map[string]any{{ + "fid": "target-fid", "file_name": "target.bin", "file": true, "size": 2, + }}}, + "metadata": map[string]any{"_size": 100, "_page": 2, "_count": 1, "_total": 101}, + }) + default: + t.Fatalf("unexpected page: %s", q.Get("_page")) + } + })) + defer srv.Close() + + d := newTestDriver(srv.URL) + visible, err := d.findUploadedFileByFID(context.Background(), "parent", "target-fid") + if err != nil { + t.Fatalf("findUploadedFileByFID: %v", err) + } + if !visible { + t.Fatal("target fid not found") + } + if listCalls != 2 { + t.Fatalf("listCalls=%d, want 2", listCalls) + } +} + +func TestUpFinishReliableMissingFidRetriesWithoutNameSizeFallback(t *testing.T) { + finishCalls := 0 + listCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/1/clouddrive/file/upload/finish": + finishCalls++ + if finishCalls == 1 { + writeJSON(w, http.StatusInternalServerError, Resp{Status: 500, Code: 500, Message: "complete_upload_lock_timeout"}) + return + } + writeJSON(w, http.StatusOK, Resp{Status: 200, Code: 0, Message: "ok"}) + case r.Method == http.MethodGet && r.URL.Path == "/1/clouddrive/file/sort": + listCalls++ + writeJSON(w, http.StatusOK, map[string]any{ + "status": 200, + "code": 0, + "data": map[string]any{"list": []map[string]any{{ + "fid": "stale-old-fid", "file_name": "857.bucket.2", "file": true, "size": 123, + }}}, + "metadata": map[string]any{"_size": 100, "_page": 1, "_count": 1, "_total": 1}, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + d := newTestDriver(srv.URL) + var pre UpPreResp + pre.Data.TaskId = "task-857" + pre.Data.ObjKey = "obj-857" + if err := d.upFinishReliable(context.Background(), pre, "parent", "857.bucket.2", 123); err != nil { + t.Fatalf("upFinishReliable: %v", err) + } + if finishCalls != 2 { + t.Fatalf("finishCalls=%d, want 2", finishCalls) + } + if listCalls != 0 { + t.Fatalf("listCalls=%d, want 0 when fid is unavailable", listCalls) + } +} + +func TestUpFinishReliableMissingFidFailsClosedAfterRetryBudget(t *testing.T) { + finishCalls := 0 + listCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/1/clouddrive/file/upload/finish": + finishCalls++ + writeJSON(w, http.StatusInternalServerError, Resp{Status: 500, Code: 500, Message: "complete_upload_lock_timeout"}) + case r.Method == http.MethodGet && r.URL.Path == "/1/clouddrive/file/sort": + listCalls++ + writeJSON(w, http.StatusOK, Resp{Status: 200, Code: 0, Message: "ok"}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + d := newTestDriver(srv.URL) + var pre UpPreResp + pre.Data.TaskId = "task-1" + pre.Data.ObjKey = "obj-1" + err := d.upFinishReliable(context.Background(), pre, "parent", "x.bucket.2", 456) + if err == nil { + t.Fatal("want error after retry budget") + } + if finishCalls != uploadControlMaxAttempts { + t.Fatalf("finishCalls=%d, want %d", finishCalls, uploadControlMaxAttempts) + } + if listCalls != 0 { + t.Fatalf("listCalls=%d, want 0 when fid is unavailable", listCalls) + } + if got := err.Error(); !strings.Contains(got, "stage finish") || !strings.Contains(got, "after 3 attempts") { + t.Fatalf("unexpected error: %s", got) + } +} + +func TestUpFinishReliableRetriesBeforeVisibilityFallback(t *testing.T) { + finishCalls := 0 + listCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/1/clouddrive/file/upload/finish": + finishCalls++ + if finishCalls == 1 { + writeJSON(w, http.StatusInternalServerError, Resp{Status: 500, Code: 500, Message: "inner error, requestId finish-1"}) + return + } + writeJSON(w, http.StatusOK, Resp{Status: 200, Code: 0, Message: "ok"}) + case r.Method == http.MethodGet && r.URL.Path == "/1/clouddrive/file/sort": + listCalls++ + writeJSON(w, http.StatusOK, map[string]any{ + "status": 200, + "code": 0, + "data": map[string]any{"list": []any{}}, + "metadata": map[string]any{"_size": 100, "_page": 1, "_count": 0, "_total": 0}, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + d := newTestDriver(srv.URL) + var pre UpPreResp + pre.Data.TaskId = "task-1" + pre.Data.ObjKey = "obj-1" + pre.Data.Fid = "fid-not-yet-visible" + if err := d.upFinishReliable(context.Background(), pre, "parent", "x.bucket.2", 456); err != nil { + t.Fatalf("upFinishReliable: %v", err) + } + if finishCalls != 2 { + t.Fatalf("finishCalls=%d, want 2", finishCalls) + } + if listCalls != 0 { + t.Fatalf("listCalls=%d, want 0 before finish retry budget is exhausted", listCalls) + } +} + +func TestUpFinishReliablePreservesNonTransientError(t *testing.T) { + finishCalls := 0 + listCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/1/clouddrive/file/upload/finish": + finishCalls++ + writeJSON(w, http.StatusForbidden, Resp{Status: 403, Code: 403, Message: "permission denied"}) + case "/1/clouddrive/file/sort": + listCalls++ + writeJSON(w, http.StatusOK, Resp{Status: 200, Code: 0, Message: "ok"}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + d := newTestDriver(srv.URL) + var pre UpPreResp + pre.Data.TaskId = "task-1" + pre.Data.ObjKey = "obj-1" + err := d.upFinishReliable(context.Background(), pre, "parent", "x.bucket.2", 1) + if err == nil { + t.Fatal("want error") + } + if finishCalls != 1 || listCalls != 0 { + t.Fatalf("finishCalls=%d listCalls=%d, want 1/0", finishCalls, listCalls) + } + if got := err.Error(); !strings.Contains(got, "stage finish") || !strings.Contains(got, "permission denied") { + t.Fatalf("unexpected error: %s", got) + } +} + +func TestWaitUploadedObjectVisibleHonorsCanceledContext(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + http.NotFound(w, r) + })) + defer srv.Close() + + d := newTestDriver(srv.URL) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + visible, err := d.waitUploadedObjectVisible(ctx, UpPreResp{}, "parent") + if visible || !errors.Is(err, context.Canceled) { + t.Fatalf("visible=%v err=%v, want false/context.Canceled", visible, err) + } + if requests != 0 { + t.Fatalf("requests=%d, want 0", requests) + } +} + +func TestWrapQuarkUploadStagePreservesCause(t *testing.T) { + cause := errors.New("boom") + err := wrapQuarkUploadStage("finish", cause) + if !errors.Is(err, cause) { + t.Fatalf("wrapped error does not preserve cause: %v", err) + } + if got, want := err.Error(), fmt.Sprintf("quark upload stage finish: %v", cause); got != want { + t.Fatalf("error=%q, want %q", got, want) + } +} From 8f75796e002a5ff715eceae13284598d0b9a1b66 Mon Sep 17 00:00:00 2001 From: Novum <284059832+NovumOrbis@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:32:35 +0800 Subject: [PATCH 2/2] ci: validate Quark finish fallback hardening --- ...uark-upload-finish-fallback-validation.yml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/quark-upload-finish-fallback-validation.yml diff --git a/.github/workflows/quark-upload-finish-fallback-validation.yml b/.github/workflows/quark-upload-finish-fallback-validation.yml new file mode 100644 index 00000000000..2c7b81aba39 --- /dev/null +++ b/.github/workflows/quark-upload-finish-fallback-validation.yml @@ -0,0 +1,37 @@ +name: quark-upload-finish-fallback-validation + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Check formatting + run: | + files="drivers/quark_uc/driver.go drivers/quark_uc/upload_reliability.go drivers/quark_uc/upload_reliability_test.go drivers/quark_uc/upload_reliability_hardening_test.go" + out="$(gofmt -l $files)" + if [ -n "$out" ]; then + printf '%s\n' "$out" + exit 1 + fi + - name: Vet Quark driver + run: go vet ./drivers/quark_uc + - name: Test Quark driver + run: go test ./drivers/quark_uc + - name: Race test Quark driver + run: go test -race ./drivers/quark_uc + - name: Shuffled repeated race test + run: go test -race -shuffle=on -count=2 ./drivers/quark_uc + - name: Test op and WebDAV + run: go test ./internal/op ./server/webdav