fix(video): 异步任务 - 完善全局轮询容错与调度策略
This commit is contained in:
@@ -664,10 +664,18 @@ func (s *Service) LogAPICall(log model.ApiCallLog) error {
|
||||
var nextPollAt *time.Time
|
||||
if stage == "create" && log.Status == model.ApiCallStatusSucceeded && log.ProviderRequestID != "" {
|
||||
stage = "accepted"
|
||||
next := time.Now().Add(2 * time.Second)
|
||||
delay := 2 * time.Second
|
||||
if log.Capability == "video" {
|
||||
delay = defaultVideoPollInterval
|
||||
}
|
||||
next := time.Now().Add(delay)
|
||||
nextPollAt = &next
|
||||
} else if stage == "poll" {
|
||||
next := time.Now().Add(5 * time.Second)
|
||||
delay := 5 * time.Second
|
||||
if log.Capability == "video" {
|
||||
delay = defaultVideoPollInterval
|
||||
}
|
||||
next := time.Now().Add(delay)
|
||||
nextPollAt = &next
|
||||
}
|
||||
if err := s.repo.UpdateTaskProviderState(log.TaskID, log.ProviderRequestID, stage, nextPollAt); err != nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/protocol"
|
||||
)
|
||||
@@ -348,6 +349,18 @@ func TestDeclarativeProtocolRuntimeExecutesCreatePollAndDownload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclarativeProtocolPollingPolicyOnlyChangesVideoBehavior(t *testing.T) {
|
||||
video := declarativeProtocolPollPolicy("video")
|
||||
if video.InitialDelay != defaultVideoPollInterval || !video.RetryTransient {
|
||||
t.Fatalf("video polling policy = %#v, want delayed resilient polling", video)
|
||||
}
|
||||
|
||||
image := declarativeProtocolPollPolicy("image")
|
||||
if image.InitialDelay != 0 || image.Interval != 2500*time.Millisecond || image.RetryTransient {
|
||||
t.Fatalf("image polling policy = %#v, want legacy immediate fail-fast polling", image)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclarativeProtocolPollRecoversFromTransientGatewayFailure(t *testing.T) {
|
||||
adapter, err := protocol.LoadManifest([]byte(`{
|
||||
"apiVersion":"yingce.plugin/v1",
|
||||
|
||||
@@ -100,7 +100,7 @@ type providerConfig struct {
|
||||
}
|
||||
|
||||
const providerHTTPTimeout = 5 * time.Minute
|
||||
const videoPollTimeout = 30 * time.Minute
|
||||
const videoPollTimeout = time.Hour
|
||||
const maxProviderResponseBytes int64 = 64 << 20
|
||||
|
||||
type providerMedia struct {
|
||||
@@ -145,6 +145,19 @@ type providerHTTPError struct {
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
type providerResponseDecodeError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e providerResponseDecodeError) Error() string { return e.Err.Error() }
|
||||
func (e providerResponseDecodeError) Unwrap() error { return e.Err }
|
||||
|
||||
type providerCircuitOpenError struct{}
|
||||
|
||||
func (providerCircuitOpenError) Error() string {
|
||||
return "当前渠道连续失败,已暂时熔断,请稍后重试"
|
||||
}
|
||||
|
||||
type providerStatePendingError struct {
|
||||
TaskID string
|
||||
Cause error
|
||||
|
||||
@@ -188,10 +188,10 @@ func doJSON(req *http.Request, target interface{}) error {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(mimeType, "json") && !json.Valid(data) {
|
||||
return fmt.Errorf("接口返回非 JSON 内容:%s", mimeType)
|
||||
return providerResponseDecodeError{Err: fmt.Errorf("接口返回非 JSON 内容:%s", mimeType)}
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
return err
|
||||
return providerResponseDecodeError{Err: err}
|
||||
}
|
||||
if payload, ok := target.(*imageResponse); ok {
|
||||
if payload.Error != nil && payload.Error.Message != "" {
|
||||
@@ -269,7 +269,7 @@ func doBinaryWithConsumer(req *http.Request, onChunk func(string, []byte)) ([]by
|
||||
return nil, "", fmt.Errorf("读取渠道熔断状态失败:%w", err)
|
||||
}
|
||||
if open {
|
||||
return nil, "", errors.New("当前渠道连续失败,已暂时熔断,请稍后重试")
|
||||
return nil, "", providerCircuitOpenError{}
|
||||
}
|
||||
slotID := channelID
|
||||
if slotID == "" {
|
||||
|
||||
@@ -40,7 +40,19 @@ func runDeclarativeProtocolTask(ctx context.Context, input canvasGenerationInput
|
||||
}
|
||||
|
||||
func runProtocolAdapterTask(ctx context.Context, input canvasGenerationInput, adapter protocol.Adapter) (map[string]interface{}, error) {
|
||||
return runProtocolAdapterTaskWithPolicy(ctx, input, adapter, defaultVideoPollPolicy())
|
||||
return runProtocolAdapterTaskWithPolicy(ctx, input, adapter, declarativeProtocolPollPolicy(input.Mode))
|
||||
}
|
||||
|
||||
func declarativeProtocolPollPolicy(mode string) videoPollPolicy {
|
||||
if mode == "video" {
|
||||
return defaultVideoPollPolicy()
|
||||
}
|
||||
return videoPollPolicy{
|
||||
Interval: 2500 * time.Millisecond,
|
||||
MaxNotFoundMisses: 1,
|
||||
MaxDownloadTries: 3,
|
||||
Sleep: sleepContext,
|
||||
}
|
||||
}
|
||||
|
||||
func runProtocolAdapterTaskWithPolicy(ctx context.Context, input canvasGenerationInput, adapter protocol.Adapter, policy videoPollPolicy) (map[string]interface{}, error) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -13,54 +15,111 @@ import (
|
||||
const defaultVideoPollInterval = 30 * time.Second
|
||||
|
||||
type videoPollPolicy struct {
|
||||
Interval time.Duration
|
||||
MaxNotFoundMisses int
|
||||
MaxDownloadTries int
|
||||
Sleep func(context.Context, time.Duration) error
|
||||
InitialDelay time.Duration
|
||||
Interval time.Duration
|
||||
MaxNotFoundMisses int
|
||||
MaxMalformedResponses int
|
||||
MaxDownloadTries int
|
||||
RetryTransient bool
|
||||
Sleep func(context.Context, time.Duration) error
|
||||
Notify func(context.Context, string, videoPollEvent, error)
|
||||
}
|
||||
|
||||
type videoPollEvent string
|
||||
|
||||
const (
|
||||
videoPollEventRetrying videoPollEvent = "retrying"
|
||||
videoPollEventRecovered videoPollEvent = "recovered"
|
||||
)
|
||||
|
||||
type videoPollOutcome struct {
|
||||
Done bool
|
||||
Result map[string]interface{}
|
||||
}
|
||||
|
||||
type videoDownloadError struct {
|
||||
TaskID string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e videoDownloadError) Error() string {
|
||||
if strings.TrimSpace(e.TaskID) == "" {
|
||||
return fmt.Sprintf("视频结果下载失败:%v", e.Cause)
|
||||
}
|
||||
return fmt.Sprintf("视频结果下载失败(任务 %s):%v", e.TaskID, e.Cause)
|
||||
}
|
||||
|
||||
func (e videoDownloadError) Unwrap() error { return e.Cause }
|
||||
|
||||
func defaultVideoPollPolicy() videoPollPolicy {
|
||||
return videoPollPolicy{
|
||||
Interval: defaultVideoPollInterval,
|
||||
MaxNotFoundMisses: 3,
|
||||
MaxDownloadTries: 3,
|
||||
Sleep: sleepContext,
|
||||
InitialDelay: defaultVideoPollInterval,
|
||||
Interval: defaultVideoPollInterval,
|
||||
MaxNotFoundMisses: 3,
|
||||
MaxMalformedResponses: 3,
|
||||
MaxDownloadTries: 3,
|
||||
RetryTransient: true,
|
||||
Sleep: sleepContext,
|
||||
Notify: notifyTaskVideoPollEvent,
|
||||
}
|
||||
}
|
||||
|
||||
func runVideoPollLoop(ctx context.Context, taskID string, policy videoPollPolicy, query func(context.Context) (videoPollOutcome, error)) (map[string]interface{}, error) {
|
||||
policy = normalizeVideoPollPolicy(policy)
|
||||
deadline := providerPollingDeadline(ctx)
|
||||
nextDelay := policy.Interval
|
||||
pollContext, cancel := context.WithDeadline(ctx, deadline)
|
||||
defer cancel()
|
||||
ctx = pollContext
|
||||
nextDelay := policy.InitialDelay
|
||||
notFoundMisses := 0
|
||||
malformedResponses := 0
|
||||
retrying := false
|
||||
for time.Now().Before(deadline) {
|
||||
if err := policy.Sleep(ctx, nextDelay); err != nil {
|
||||
return nil, err
|
||||
if nextDelay > 0 {
|
||||
if err := policy.Sleep(ctx, nextDelay); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
outcome, err := query(ctx)
|
||||
if err != nil {
|
||||
if !policy.RetryTransient {
|
||||
return nil, err
|
||||
}
|
||||
retry, notFound := retryableVideoPollError(ctx, err)
|
||||
if !retry {
|
||||
return nil, err
|
||||
}
|
||||
if notFound {
|
||||
malformed := isTransientResponseDecodeError(err)
|
||||
if malformed {
|
||||
malformedResponses++
|
||||
notFoundMisses = 0
|
||||
if malformedResponses >= policy.MaxMalformedResponses {
|
||||
return nil, err
|
||||
}
|
||||
} else if notFound {
|
||||
malformedResponses = 0
|
||||
notFoundMisses++
|
||||
if notFoundMisses >= policy.MaxNotFoundMisses {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
malformedResponses = 0
|
||||
notFoundMisses = 0
|
||||
}
|
||||
if !retrying {
|
||||
retrying = true
|
||||
policy.Notify(ctx, taskID, videoPollEventRetrying, err)
|
||||
}
|
||||
nextDelay = max(policy.Interval, providerRetryAfter(err))
|
||||
continue
|
||||
}
|
||||
notFoundMisses = 0
|
||||
malformedResponses = 0
|
||||
nextDelay = policy.Interval
|
||||
if retrying {
|
||||
retrying = false
|
||||
policy.Notify(ctx, taskID, videoPollEventRecovered, nil)
|
||||
}
|
||||
if outcome.Done {
|
||||
return outcome.Result, nil
|
||||
}
|
||||
@@ -78,12 +137,18 @@ func normalizeVideoPollPolicy(policy videoPollPolicy) videoPollPolicy {
|
||||
if policy.MaxNotFoundMisses <= 0 {
|
||||
policy.MaxNotFoundMisses = 3
|
||||
}
|
||||
if policy.MaxMalformedResponses <= 0 {
|
||||
policy.MaxMalformedResponses = 3
|
||||
}
|
||||
if policy.MaxDownloadTries <= 0 {
|
||||
policy.MaxDownloadTries = 3
|
||||
}
|
||||
if policy.Sleep == nil {
|
||||
policy.Sleep = sleepContext
|
||||
}
|
||||
if policy.Notify == nil {
|
||||
policy.Notify = func(context.Context, string, videoPollEvent, error) {}
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
@@ -98,23 +163,37 @@ func runVideoDownload(ctx context.Context, taskID string, policy videoPollPolicy
|
||||
lastErr = err
|
||||
retry, _ := retryableVideoPollError(ctx, err)
|
||||
if !retry || attempt == policy.MaxDownloadTries {
|
||||
return nil, "", err
|
||||
return nil, "", videoDownloadError{TaskID: taskID, Cause: err}
|
||||
}
|
||||
delay := max(policy.Interval, providerRetryAfter(err))
|
||||
if err := policy.Sleep(ctx, delay); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
return nil, "", lastErr
|
||||
return nil, "", videoDownloadError{TaskID: taskID, Cause: lastErr}
|
||||
}
|
||||
|
||||
func retryableVideoPollError(ctx context.Context, err error) (retry bool, notFound bool) {
|
||||
if err == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) {
|
||||
return false, false
|
||||
}
|
||||
var downloadError videoDownloadError
|
||||
if errors.As(err, &downloadError) {
|
||||
return false, false
|
||||
}
|
||||
if code, _ := ChannelSlotFailureDetails(err); code != "" {
|
||||
return true, false
|
||||
}
|
||||
var circuitOpen providerCircuitOpenError
|
||||
if errors.As(err, &circuitOpen) {
|
||||
return true, false
|
||||
}
|
||||
if isTransientResponseDecodeError(err) {
|
||||
return true, false
|
||||
}
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.ErrClosedPipe) {
|
||||
return true, false
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true, false
|
||||
}
|
||||
@@ -140,6 +219,33 @@ func retryableVideoPollError(ctx context.Context, err error) (retry bool, notFou
|
||||
return false, false
|
||||
}
|
||||
|
||||
func isTransientResponseDecodeError(err error) bool {
|
||||
var decodeError providerResponseDecodeError
|
||||
if errors.As(err, &decodeError) {
|
||||
return true
|
||||
}
|
||||
var syntaxError *json.SyntaxError
|
||||
return errors.As(err, &syntaxError)
|
||||
}
|
||||
|
||||
func notifyTaskVideoPollEvent(ctx context.Context, _ string, event videoPollEvent, eventErr error) {
|
||||
metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
|
||||
if !ok || metadata.Service == nil || metadata.Service.repo == nil || metadata.TaskID == "" {
|
||||
return
|
||||
}
|
||||
message := "上游视频查询暂时异常,将继续轮询原任务"
|
||||
level := "warn"
|
||||
payload := ""
|
||||
if eventErr != nil {
|
||||
payload = eventErr.Error()
|
||||
}
|
||||
if event == videoPollEventRecovered {
|
||||
message = "上游视频查询已恢复"
|
||||
level = "info"
|
||||
}
|
||||
_ = metadata.Service.log(metadata.UserID, metadata.TaskID, level, message, payload)
|
||||
}
|
||||
|
||||
func isProviderTaskNotReadyError(httpErr providerHTTPError) bool {
|
||||
if httpErr.StatusCode != http.StatusBadRequest && httpErr.StatusCode != http.StatusNotFound {
|
||||
return false
|
||||
|
||||
@@ -2,7 +2,9 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
@@ -33,6 +35,7 @@ func TestVideoPollRecoversFromTransientHTTPError(t *testing.T) {
|
||||
func TestVideoPollWaitsBeforeFirstQueryAndUsesRetryAfter(t *testing.T) {
|
||||
var waits []time.Duration
|
||||
policy := fastVideoPollPolicy()
|
||||
policy.InitialDelay = 10 * time.Millisecond
|
||||
policy.Interval = 10 * time.Millisecond
|
||||
policy.Sleep = func(_ context.Context, delay time.Duration) error {
|
||||
waits = append(waits, delay)
|
||||
@@ -105,6 +108,68 @@ func TestVideoPollSuccessfulPendingResponseResetsNotFoundCounter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoPollRetriesMalformedResponseTwiceThenSucceeds(t *testing.T) {
|
||||
attempts := 0
|
||||
result, err := runVideoPollLoop(context.Background(), "provider-task-1", fastVideoPollPolicy(), func(context.Context) (videoPollOutcome, error) {
|
||||
attempts++
|
||||
if attempts <= 2 {
|
||||
return videoPollOutcome{}, &json.SyntaxError{Offset: int64(attempts)}
|
||||
}
|
||||
return videoPollOutcome{Done: true, Result: map[string]interface{}{"mode": "video"}}, nil
|
||||
})
|
||||
if err != nil || attempts != 3 || result["mode"] != "video" {
|
||||
t.Fatalf("result = %#v, error = %v, attempts = %d", result, err, attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoPollStopsAfterThreeMalformedResponses(t *testing.T) {
|
||||
attempts := 0
|
||||
_, err := runVideoPollLoop(context.Background(), "provider-task-1", fastVideoPollPolicy(), func(context.Context) (videoPollOutcome, error) {
|
||||
attempts++
|
||||
return videoPollOutcome{}, &json.SyntaxError{Offset: int64(attempts)}
|
||||
})
|
||||
var syntaxError *json.SyntaxError
|
||||
if !errors.As(err, &syntaxError) || attempts != 3 {
|
||||
t.Fatalf("error = %#v, attempts = %d, want three attempts and syntax error", err, attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoPollTreatsCircuitAndInterruptedReadsAsTransient(t *testing.T) {
|
||||
for _, err := range []error{providerCircuitOpenError{}, io.ErrUnexpectedEOF, io.ErrClosedPipe} {
|
||||
retry, notFound := retryableVideoPollError(context.Background(), err)
|
||||
if !retry || notFound {
|
||||
t.Fatalf("error %T classified as retry=%v notFound=%v", err, retry, notFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoPollNotifiesOnlyWhenRetryStartsAndRecovers(t *testing.T) {
|
||||
var events []videoPollEvent
|
||||
policy := fastVideoPollPolicy()
|
||||
policy.Notify = func(_ context.Context, _ string, event videoPollEvent, _ error) {
|
||||
events = append(events, event)
|
||||
}
|
||||
attempts := 0
|
||||
_, err := runVideoPollLoop(context.Background(), "provider-task-1", policy, func(context.Context) (videoPollOutcome, error) {
|
||||
attempts++
|
||||
switch attempts {
|
||||
case 1, 2:
|
||||
return videoPollOutcome{}, providerHTTPError{StatusCode: http.StatusBadGateway}
|
||||
case 3:
|
||||
return videoPollOutcome{}, nil
|
||||
default:
|
||||
return videoPollOutcome{Done: true, Result: map[string]interface{}{"mode": "video"}}, nil
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []videoPollEvent{videoPollEventRetrying, videoPollEventRecovered}
|
||||
if len(events) != len(want) || events[0] != want[0] || events[1] != want[1] {
|
||||
t.Fatalf("events = %#v, want %#v", events, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoPollContextCancellationInterruptsInitialWait(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
@@ -121,6 +186,26 @@ func TestVideoPollContextCancellationInterruptsInitialWait(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoPollAppliesFallbackDeadlineToWaitAndQuery(t *testing.T) {
|
||||
policy := fastVideoPollPolicy()
|
||||
waitHadDeadline := false
|
||||
queryHadDeadline := false
|
||||
policy.Sleep = func(ctx context.Context, _ time.Duration) error {
|
||||
_, waitHadDeadline = ctx.Deadline()
|
||||
return nil
|
||||
}
|
||||
_, err := runVideoPollLoop(context.Background(), "provider-task-1", policy, func(ctx context.Context) (videoPollOutcome, error) {
|
||||
_, queryHadDeadline = ctx.Deadline()
|
||||
return videoPollOutcome{Done: true, Result: map[string]interface{}{"mode": "video"}}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !waitHadDeadline || !queryHadDeadline {
|
||||
t.Fatalf("wait deadline = %v, query deadline = %v", waitHadDeadline, queryHadDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoDownloadRetriesTransientFailure(t *testing.T) {
|
||||
attempts := 0
|
||||
waits := 0
|
||||
@@ -156,8 +241,21 @@ func TestVideoDownloadStopsAfterThreeTransientFailures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoPollDoesNotRetryExhaustedDownload(t *testing.T) {
|
||||
attempts := 0
|
||||
_, err := runVideoPollLoop(context.Background(), "provider-task-1", fastVideoPollPolicy(), func(context.Context) (videoPollOutcome, error) {
|
||||
attempts++
|
||||
return videoPollOutcome{}, videoDownloadError{TaskID: "provider-task-1", Cause: providerHTTPError{StatusCode: http.StatusBadGateway}}
|
||||
})
|
||||
var downloadError videoDownloadError
|
||||
if !errors.As(err, &downloadError) || attempts != 1 {
|
||||
t.Fatalf("error = %#v, poll attempts = %d, want one exhausted download", err, attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func fastVideoPollPolicy() videoPollPolicy {
|
||||
policy := defaultVideoPollPolicy()
|
||||
policy.InitialDelay = time.Millisecond
|
||||
policy.Interval = time.Millisecond
|
||||
policy.Sleep = func(context.Context, time.Duration) error { return nil }
|
||||
return policy
|
||||
|
||||
@@ -26,6 +26,25 @@ func TestVideoTaskTimeoutHasFiveMinuteSafetyFloor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultVideoTaskTimeoutIsOneHour(t *testing.T) {
|
||||
policy := defaultRuntimePolicy().Task
|
||||
if policy.VideoTimeoutMinutes != 60 {
|
||||
t.Fatalf("default video timeout = %d minutes, want 60", policy.VideoTimeoutMinutes)
|
||||
}
|
||||
if got := taskExecutionTimeoutWithPolicy("canvas_video", policy); got != time.Hour {
|
||||
t.Fatalf("video execution timeout = %s, want 1h", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderPollingDeadlineDefaultsToOneHour(t *testing.T) {
|
||||
startedAt := time.Now()
|
||||
deadline := providerPollingDeadline(context.Background())
|
||||
remaining := deadline.Sub(startedAt)
|
||||
if remaining < time.Hour || remaining > time.Hour+time.Second {
|
||||
t.Fatalf("provider polling deadline = %s, want approximately 1h", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyResumableNewAPIChannel2VideoDeadlinesStayRunning(t *testing.T) {
|
||||
svc := &Service{}
|
||||
input, err := json.Marshal(canvasGenerationInput{Mode: "video", Config: providerConfig{BaseURL: "https://example.com", InterfaceType: string(model.ChannelInterfaceNewAPIChannel2)}})
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
@@ -12,6 +13,43 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestVideoPollLogSchedulesNextCheckThirtySecondsLater(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:provider-poll-schedule?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Task{}, &model.ApiCallLog{}, &model.ModelPricing{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := model.Task{ID: "task-1", UserID: "user-1", Type: "canvas_video", Status: model.TaskStatusRunning}
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := model.ApiCallLog{ID: "api-log-1", UserID: task.UserID, TaskID: task.ID, Capability: "video", RequestKind: "create", ProviderRequestID: "provider-task-1", CreatedAt: time.Now()}
|
||||
if err := db.Create(&root).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &Service{repo: repository.New(db)}
|
||||
startedAt := time.Now()
|
||||
if err := service.LogAPICall(model.ApiCallLog{
|
||||
ID: "poll-log-1", UserID: task.UserID, TaskID: task.ID, Capability: "video", RequestKind: "poll",
|
||||
ProviderRequestID: "provider-task-1", Status: model.ApiCallStatusSucceeded, StatusCode: 200, CreatedAt: startedAt,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored model.Task
|
||||
if err := db.First(&stored, "id = ?", task.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.NextPollAt == nil {
|
||||
t.Fatal("next poll time is nil")
|
||||
}
|
||||
delay := stored.NextPollAt.Sub(startedAt)
|
||||
if delay < 30*time.Second || delay > 31*time.Second {
|
||||
t.Fatalf("next poll delay = %s, want approximately 30s", delay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureFailedProviderAttemptLoggedFillsPreflightGapOnce(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:provider-preflight-log?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
|
||||
@@ -451,7 +451,7 @@ func (s *Service) runRunningHubWorkflow(ctx context.Context, input canvasGenerat
|
||||
root := runningHubRootURL(input.Config.BaseURL)
|
||||
apiKey := runningHubAPIKey(input.Config)
|
||||
if resumed := resumedProviderRequestID(ctx); resumed != "" {
|
||||
return s.pollRunningHubWorkflow(ctx, input.Config, root, resumed)
|
||||
return s.pollRunningHubWorkflow(ctx, input.Config, root, resumed, input.Mode)
|
||||
}
|
||||
workflowID := strings.TrimSpace(input.Config.WorkflowID)
|
||||
webappID := strings.TrimSpace(input.Config.WebappID)
|
||||
@@ -535,7 +535,7 @@ func (s *Service) runRunningHubWorkflow(ctx context.Context, input canvasGenerat
|
||||
metadata, _ := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
|
||||
_ = s.log(metadata.UserID, metadata.TaskID, "error", "RunningHub 请求状态保存失败", taskID+":"+err.Error())
|
||||
}
|
||||
return s.pollRunningHubWorkflow(ctx, input.Config, root, taskID)
|
||||
return s.pollRunningHubWorkflow(ctx, input.Config, root, taskID, input.Mode)
|
||||
}
|
||||
|
||||
func runningHubWorkflowFailureMessage(response map[string]any) string {
|
||||
@@ -1717,7 +1717,59 @@ func (s *Service) runningHubJSON(ctx context.Context, config providerConfig, end
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) pollRunningHubWorkflow(ctx context.Context, config providerConfig, root string, taskID string) (map[string]interface{}, error) {
|
||||
func (s *Service) pollRunningHubWorkflow(ctx context.Context, config providerConfig, root string, taskID string, mode string) (map[string]interface{}, error) {
|
||||
if mode == "video" {
|
||||
return s.pollRunningHubVideoWorkflowWithPolicy(ctx, config, root, taskID, defaultVideoPollPolicy())
|
||||
}
|
||||
return s.pollRunningHubWorkflowLegacy(ctx, config, root, taskID)
|
||||
}
|
||||
|
||||
func (s *Service) pollRunningHubVideoWorkflowWithPolicy(ctx context.Context, config providerConfig, root string, taskID string, policy videoPollPolicy) (map[string]interface{}, error) {
|
||||
return runVideoPollLoop(ctx, taskID, policy, func(ctx context.Context) (videoPollOutcome, error) {
|
||||
var response map[string]any
|
||||
err := s.runningHubJSON(withProviderRequestKind(ctx, "poll"), config, root+"/task/openapi/outputs", map[string]any{"apiKey": runningHubAPIKey(config), "taskId": taskID}, &response)
|
||||
if err != nil {
|
||||
return videoPollOutcome{}, fmt.Errorf("RunningHub 查询任务失败:%w", err)
|
||||
}
|
||||
code, validCode := runningHubPayloadCode(response)
|
||||
if !validCode {
|
||||
validCode = len(runningHubOutputURLs(response["data"])) > 0
|
||||
code = 0
|
||||
}
|
||||
if !validCode {
|
||||
return videoPollOutcome{}, errors.New("RunningHub 查询响应缺少可识别状态")
|
||||
}
|
||||
if code == 0 {
|
||||
urls := runningHubOutputURLs(response["data"])
|
||||
if len(urls) == 0 {
|
||||
return videoPollOutcome{}, errors.New("RunningHub 任务成功但没有返回产物")
|
||||
}
|
||||
for index, rawURL := range urls {
|
||||
urls[index] = resolveRunningHubOutputURL(root, rawURL)
|
||||
}
|
||||
result, err := s.downloadWorkflowVideoOutputs(ctx, urls, taskID, policy)
|
||||
if err != nil {
|
||||
return videoPollOutcome{}, err
|
||||
}
|
||||
_ = s.updateWorkflowProviderState(ctx, taskID, "succeeded", nil)
|
||||
return videoPollOutcome{Done: true, Result: result}, nil
|
||||
}
|
||||
if code == 805 || code == 806 {
|
||||
return videoPollOutcome{}, fmt.Errorf("RunningHub 任务失败:%s", runningHubFailureMessage(response))
|
||||
}
|
||||
stage := "running"
|
||||
if code == 813 {
|
||||
stage = "queued"
|
||||
} else if code != 804 {
|
||||
stage = "pending"
|
||||
}
|
||||
next := time.Now().Add(policy.Interval)
|
||||
_ = s.updateWorkflowProviderState(ctx, taskID, stage, &next)
|
||||
return videoPollOutcome{}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) pollRunningHubWorkflowLegacy(ctx context.Context, config providerConfig, root string, taskID string) (map[string]interface{}, error) {
|
||||
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
|
||||
var response map[string]any
|
||||
err := s.runningHubJSON(withProviderRequestKind(ctx, "poll"), config, root+"/task/openapi/outputs", map[string]any{"apiKey": runningHubAPIKey(config), "taskId": taskID}, &response)
|
||||
@@ -1768,6 +1820,14 @@ func runningHubAPIKey(config providerConfig) string {
|
||||
}
|
||||
|
||||
func (s *Service) downloadWorkflowOutputs(ctx context.Context, urls []string) (map[string]interface{}, error) {
|
||||
return s.downloadWorkflowOutputsWithPolicy(ctx, urls, "", nil)
|
||||
}
|
||||
|
||||
func (s *Service) downloadWorkflowVideoOutputs(ctx context.Context, urls []string, taskID string, policy videoPollPolicy) (map[string]interface{}, error) {
|
||||
return s.downloadWorkflowOutputsWithPolicy(ctx, urls, taskID, &policy)
|
||||
}
|
||||
|
||||
func (s *Service) downloadWorkflowOutputsWithPolicy(ctx context.Context, urls []string, taskID string, policy *videoPollPolicy) (map[string]interface{}, error) {
|
||||
images := make([]map[string]interface{}, 0)
|
||||
var video, audio map[string]interface{}
|
||||
for _, rawURL := range urls {
|
||||
@@ -1793,7 +1853,16 @@ func (s *Service) downloadWorkflowOutputs(ctx context.Context, urls []string) (m
|
||||
if !isPublicMediaURL(rawURL) {
|
||||
continue
|
||||
}
|
||||
data, mimeType, err := getExternalBinary(withProviderRequestKind(ctx, "download"), rawURL)
|
||||
var data []byte
|
||||
var mimeType string
|
||||
var err error
|
||||
if policy == nil {
|
||||
data, mimeType, err = getExternalBinary(withProviderRequestKind(ctx, "download"), rawURL)
|
||||
} else {
|
||||
data, mimeType, err = runVideoDownload(ctx, taskID, *policy, func(ctx context.Context) ([]byte, string, error) {
|
||||
return getExternalBinary(withProviderRequestKind(ctx, "download"), rawURL)
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载 RunningHub 产物失败:%w", err)
|
||||
}
|
||||
|
||||
41
backend/internal/app/workflow_video_polling_test.go
Normal file
41
backend/internal/app/workflow_video_polling_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunningHubVideoPollRecoversFromTransientGatewayFailure(t *testing.T) {
|
||||
pollCalls := 0
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/task/openapi/outputs":
|
||||
pollCalls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if pollCalls == 1 {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":[{"fileUrl":"` + server.URL + `/result.mp4"}]}`))
|
||||
case "/result.mp4":
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
_, _ = w.Write([]byte("video"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := providerConfig{BaseURL: server.URL, APIKey: "test-key", AllowLocalChannel: true}
|
||||
ctx := withProviderOutboundPolicy(context.Background(), config)
|
||||
result, err := (&Service{}).pollRunningHubVideoWorkflowWithPolicy(ctx, config, server.URL, "provider-task-1", fastVideoPollPolicy())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["mode"] != "video" || pollCalls != 2 {
|
||||
t.Fatalf("result = %#v, poll calls = %d", result, pollCalls)
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func DefaultRuntimePolicy() RuntimePolicySetting {
|
||||
ImageTimeoutMinutes: 8,
|
||||
TextTimeoutMinutes: 8,
|
||||
AudioTimeoutMinutes: 8,
|
||||
VideoTimeoutMinutes: 30,
|
||||
VideoTimeoutMinutes: 60,
|
||||
StoryboardTimeoutMinutes: 20,
|
||||
DefaultTimeoutMinutes: 10,
|
||||
},
|
||||
|
||||
@@ -26,26 +26,18 @@ description: Channel 2 视频任务创建、轮询与上游任务同步延迟处
|
||||
|
||||
宿主只在同时满足以下条件时把该响应视为“任务状态尚未同步”:
|
||||
|
||||
1. 渠道接口类型精确为 `newapi-channel-2`;
|
||||
2. 已经保存非空 Provider Task ID;
|
||||
3. HTTP 状态为 `400`;
|
||||
4. JSON 的 `code` 或 `message` 精确等于 `task_not_exist`。
|
||||
1. 已经保存非空 Provider Task ID;
|
||||
2. HTTP 状态为 `400` 或 `404`;
|
||||
3. JSON 的 `code` 或 `message` 精确等于 `task_not_exist`、`task_not_found` 或对应的空格拼写。
|
||||
|
||||
不符合条件的 HTTP 错误、非 JSON 正文或其他错误码仍立即按失败语义处理。
|
||||
该判断属于宿主统一的视频轮询语义,不再按渠道名分支。不符合条件的 400/401/403/422 等参数、鉴权或权限错误仍立即按失败语义处理。
|
||||
|
||||
## 轮询与延期
|
||||
|
||||
新任务首次查询前等待 `2` 秒;同步错误在同一次执行中按 `2` 秒、`3` 秒、`5` 秒、`5` 秒退避,最多短期查询 5 次。短期窗口耗尽后,Worker 不发送新的创建请求,也不切换渠道或退款,而是:
|
||||
新任务和恢复任务都在首次查询前等待 30 秒,后续统一每 30 秒查询。连续三次 `task_not_exist` 或 404 才结束;中间任何一次正常的 pending/running 响应都会清零连续计数。HTTP 408/409/425/429/5xx、临时网络错误、断流、渠道槽位和短时熔断继续查询原 Provider Task ID,不发送第二次创建请求。
|
||||
|
||||
- 保持本地任务状态为 `running`;
|
||||
- 保留原 `provider_request_id` 和计费状态;
|
||||
- 释放当前租约并把 `next_poll_at` 延后 15 秒;
|
||||
- 下一轮从已保存的 Provider Task ID 恢复,只发送查询请求。
|
||||
|
||||
`task_not_exist` 的跨轮次同步等待从本地任务首次开始执行起最多持续 5 分钟。超过该上限仍未得到有效任务状态时,Worker 将任务置为失败并释放活跃任务与计费预留,避免无效 Provider Task ID 永久占用用户任务名额。该限制只针对持续的 `task_not_exist`;查询接口已经返回正常 pending/running 状态后的长视频生成仍按视频任务超时和延期策略恢复。
|
||||
|
||||
因此,上游生成耗时 500 至 600 秒时,只要查询接口返回有效的处理中状态,本地会持续以低频查询等待完成,不会把正常排队误报为“模型服务拒绝了请求”。视频真正返回失败状态时,仍按供应商失败处理。
|
||||
默认视频执行窗口为 60 分钟;管理员已经保存的运行时策略仍按保存值执行。视频真正返回 failed/cancelled/expired 等终态时,仍按供应商失败处理。
|
||||
|
||||
## 兼容边界
|
||||
|
||||
该容错只适用于 `newapi-channel-2` 视频任务。其他 NewAPI 接口、其他 Provider、缺少 Provider Task ID 的任务,以及真实参数错误不进入延期路径。
|
||||
该容错适用于所有异步视频声明式插件和宿主内置协议。缺少 Provider Task ID 的创建失败、真实参数错误、鉴权错误和非法插件映射不进入重试路径。
|
||||
|
||||
@@ -5,6 +5,18 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 异步视频统一轮询容错
|
||||
|
||||
- 声明式视频插件、遗留内置视频协议和 RunningHub 视频工作流统一在创建成功后等待 30 秒首次查询,后续每 30 秒查询;图片、音频和文本协议不受影响。
|
||||
- 查询阶段的 HTTP 408/409/425/429/5xx、临时网络错误、断流、渠道槽位或熔断,以及最多连续两次非法 JSON 不再直接结束任务;HTTP 404 和结构化 `task_not_exist` 连续第三次才结束。
|
||||
- 结果下载复用同一暂态错误分类,最多尝试 3 次且不重新创建或轮询上游任务。默认视频任务执行窗口由 30 分钟改为 60 分钟;已保存的管理员策略不会被覆盖。
|
||||
- [x] `cd backend && go test ./internal/app ./internal/platform -count=1`:通过。
|
||||
- [x] `cd backend && go test ./... -count=1`、`go vet ./...`:通过。
|
||||
- [ ] 在测试渠道制造一次查询 502,确认任务保持运行、供应商任务 ID 不变,后续查询恢复后正常完成且不重复扣费。
|
||||
- [ ] 检查任务日志只出现一次「查询暂时异常」与一次「查询已恢复」,请求明细仍保留每次上游调用。
|
||||
- [ ] 让视频下载首次返回 502、第二次成功,确认宿主不重新查询或创建任务;连续三次下载失败时应返回包含供应商任务 ID 的错误。
|
||||
- [ ] 将管理员视频超时仍设为旧值的升级环境启动一次,确认保存值保持不变;新安装环境默认显示 60 分钟。
|
||||
|
||||
## 画布图片资源缓存链路优化
|
||||
|
||||
消除首屏满屏转圈与"一直在下载":后端图片资源改 30 天磁盘强缓存(`Cache-Control: max-age=2592000`),前端内存防抖 IndexedDB 元数据写入、O(1) 淘汰预检、并发下载 4→16,视口内远程图片直链首帧上屏(走原生磁盘缓存秒开),Blob 缓存后台平滑升级。已编译通过,需浏览器最终确认:
|
||||
|
||||
Reference in New Issue
Block a user