Files
new-api/service/task_polling_test.go
T

724 lines
26 KiB
Go

package service
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
taskdto "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/bytedance/gopkg/util/gopool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type taskPollingFetchAdaptor struct {
mu sync.Mutex
taskIDs []string
fetched chan string
blockTaskID string
blockStarted chan struct{}
releaseBlock chan struct{}
blockOnce sync.Once
}
type batchPollingAdaptor struct {
taskPollingFetchAdaptor
batchCalls int
batchIDs []string
results map[string]*BatchTaskResult
}
func (a *batchPollingAdaptor) FetchMode() string { return "batch" }
func (a *batchPollingAdaptor) FetchBatchTasks(_ string, _ string, taskIDs []string, _ string) (*http.Response, error) {
a.batchCalls++
a.batchIDs = append([]string(nil), taskIDs...)
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(`{}`)))}, nil
}
func (a *batchPollingAdaptor) ParseBatchResult([]byte) (map[string]*BatchTaskResult, error) {
if a.results != nil {
return a.results, nil
}
results := make(map[string]*BatchTaskResult, len(a.batchIDs))
for _, taskID := range a.batchIDs {
results[taskID] = &BatchTaskResult{TaskInfo: relaycommon.TaskInfo{TaskID: taskID, Status: model.TaskStatusInProgress, Url: "https://example.com/result"}}
}
return results, nil
}
func (a *taskPollingFetchAdaptor) Init(_ *relaycommon.RelayInfo) {}
func (a *taskPollingFetchAdaptor) FetchTask(_ string, _ string, body map[string]any, _ string) (*http.Response, error) {
taskID, _ := body["task_id"].(string)
if taskID == a.blockTaskID && a.releaseBlock != nil {
a.blockOnce.Do(func() {
if a.blockStarted != nil {
close(a.blockStarted)
}
})
<-a.releaseBlock
}
a.mu.Lock()
a.taskIDs = append(a.taskIDs, taskID)
a.mu.Unlock()
if a.fetched != nil {
select {
case a.fetched <- taskID:
default:
}
}
response := taskdto.TaskResponse[model.Task]{
Code: taskdto.TaskSuccessCode,
Data: model.Task{
TaskID: taskID,
Status: model.TaskStatusInProgress,
Progress: "30%",
},
}
responseBody, err := common.Marshal(response)
if err != nil {
return nil, err
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(responseBody)),
}, nil
}
func (a *taskPollingFetchAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) {
return &relaycommon.TaskInfo{Status: model.TaskStatusInProgress}, nil
}
func (a *taskPollingFetchAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int {
return 0
}
func (a *taskPollingFetchAdaptor) fetchCount() int {
a.mu.Lock()
defer a.mu.Unlock()
return len(a.taskIDs)
}
func (a *taskPollingFetchAdaptor) fetchedTaskIDs() []string {
a.mu.Lock()
defer a.mu.Unlock()
return append([]string(nil), a.taskIDs...)
}
func TestRedactVideoResponseBodyPreservesPollingPayloadShape(t *testing.T) {
rawVideo := strings.Repeat("a", 300)
body, err := common.Marshal(map[string]any{
"done": true,
"name": "operations/provider-task",
"response": map[string]any{
"bytesBase64Encoded": "secret-bytes",
"video": rawVideo,
"videos": []any{
map[string]any{
"bytesBase64Encoded": "other-secret-bytes",
"mimeType": "video/mp4",
"uri": "https://media.example/video.mp4",
},
},
},
})
require.NoError(t, err)
var stored map[string]any
require.NoError(t, common.Unmarshal(redactVideoResponseBody(body), &stored))
assert.Equal(t, true, stored["done"])
assert.Equal(t, "operations/provider-task", stored["name"])
response, ok := stored["response"].(map[string]any)
require.True(t, ok)
assert.NotContains(t, response, "bytesBase64Encoded")
assert.Equal(t, strings.Repeat("a", 256)+"...", response["video"])
videos, ok := response["videos"].([]any)
require.True(t, ok)
require.Len(t, videos, 1)
video, ok := videos[0].(map[string]any)
require.True(t, ok)
assert.NotContains(t, video, "bytesBase64Encoded")
assert.Equal(t, "video/mp4", video["mimeType"])
assert.Equal(t, "https://media.example/video.mp4", video["uri"])
}
func seedTaskPollingChannel(t *testing.T, id int, disableSleep bool) {
t.Helper()
ch := &model.Channel{
Id: id,
Type: constant.ChannelTypeKling,
Name: "polling_channel",
Key: "sk-test",
Status: common.ChannelStatusEnabled,
}
if disableSleep {
ch.SetOtherSettings(dto.ChannelOtherSettings{DisableTaskPollingSleep: true})
}
require.NoError(t, model.DB.Create(ch).Error)
}
func seedPollingTask(t *testing.T, channelID int, publicID string, upstreamID string) *model.Task {
t.Helper()
task := &model.Task{
TaskID: publicID,
Platform: constant.TaskPlatform("kling"),
UserId: 1,
ChannelId: channelID,
Action: constant.TaskActionImageToVideo,
Status: model.TaskStatusInProgress,
Progress: "30%",
CreatedAt: time.Now().Unix(),
UpdatedAt: time.Now().Unix(),
PrivateData: model.TaskPrivateData{
UpstreamTaskID: upstreamID,
},
}
require.NoError(t, model.DB.Create(task).Error)
return task
}
func TestUpdateVideoTasksDefaultSleepWaitsBetweenTasks(t *testing.T) {
truncate(t)
const channelID = 101
seedTaskPollingChannel(t, channelID, false)
first := seedPollingTask(t, channelID, "task_public_1", "upstream_1")
second := seedPollingTask(t, channelID, "task_public_2", "upstream_2")
adaptor := &taskPollingFetchAdaptor{}
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return adaptor }
t.Cleanup(func() { GetTaskAdaptorFunc = previousFactory })
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := UpdateVideoTasks(ctx, constant.TaskPlatform("kling"), map[int][]string{
channelID: {
first.GetUpstreamTaskID(),
second.GetUpstreamTaskID(),
},
}, map[string]*model.Task{
first.GetUpstreamTaskID(): first,
second.GetUpstreamTaskID(): second,
})
require.ErrorIs(t, err, context.DeadlineExceeded)
assert.Equal(t, 1, adaptor.fetchCount())
}
func TestDispatchPlatformUpdateUsesFetchMode(t *testing.T) {
truncate(t)
const channelID = 109
seedTaskPollingChannel(t, channelID, true)
task := seedPollingTask(t, channelID, "task_batch", "upstream_batch")
taskChannels := map[int][]string{channelID: {task.GetUpstreamTaskID()}}
tasks := map[string]*model.Task{task.GetUpstreamTaskID(): task}
batch := &batchPollingAdaptor{}
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return batch }
DispatchPlatformUpdate(context.Background(), "batch-plugin", taskChannels, tasks)
assert.Equal(t, 1, batch.batchCalls)
assert.Equal(t, 0, batch.fetchCount())
var persisted model.Task
require.NoError(t, model.DB.First(&persisted, task.ID).Error)
assert.Equal(t, "https://example.com/result", persisted.GetResultURL())
perTask := &taskPollingFetchAdaptor{}
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return perTask }
DispatchPlatformUpdate(context.Background(), "per-task-plugin", taskChannels, tasks)
assert.Equal(t, 1, perTask.fetchCount())
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return nil }
assert.NotPanics(t, func() { DispatchPlatformUpdate(context.Background(), "missing-plugin", taskChannels, tasks) })
GetTaskAdaptorFunc = previousFactory
}
func TestUpdateBatchTasksSettlesTieredUsageForTerminalStates(t *testing.T) {
testCases := []struct {
name string
status model.TaskStatus
units float64
actualQuota int
}{
{name: "success with usage", status: model.TaskStatusSuccess, units: 3, actualQuota: 3_000},
{name: "failure with usage", status: model.TaskStatusFailure, units: 3, actualQuota: 0},
{name: "success with zero usage", status: model.TaskStatusSuccess, units: 0, actualQuota: 0},
{name: "failure with zero usage", status: model.TaskStatusFailure, units: 0, actualQuota: 0},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
truncate(t)
const userID, tokenID, channelID = 41, 41, 141
const initialQuota, preConsumedQuota = 10_000, 5_000
const tokenRemain = 8_000
seedUser(t, userID, initialQuota)
seedToken(t, tokenID, userID, "sk-batch-tiered", tokenRemain)
seedTaskPollingChannel(t, channelID, true)
expression := `tier("actual", u("units"))`
task := makeTask(userID, channelID, preConsumedQuota, tokenID, BillingSourceWallet, 0)
task.TaskID = "task_batch_tiered_" + string(testCase.status)
task.Platform = "batch-plugin"
task.PrivateData.UpstreamTaskID = "upstream_batch_tiered_" + string(testCase.status)
task.SetData(map[string]any{"provider_payload": "must-be-preserved"})
task.PrivateData.BillingContext.TieredSnapshot = &billingexpr.BillingSnapshot{
ExprString: expression,
ExprHash: billingexpr.ExprHashString(expression),
GroupRatio: 1,
QuotaPerUnit: 1_000,
ExprVersion: 1,
TaskUsageBilling: true,
}
require.NoError(t, model.DB.Create(task).Error)
upstreamID := task.GetUpstreamTaskID()
reason := ""
if testCase.status == model.TaskStatusFailure {
reason = "upstream failed"
}
result := &BatchTaskResult{TaskInfo: relaycommon.TaskInfo{
TaskID: upstreamID,
Status: string(testCase.status),
Reason: reason,
UsageFacts: map[string]any{"units": testCase.units},
}}
adaptor := &batchPollingAdaptor{results: map[string]*BatchTaskResult{upstreamID: result}}
taskIDs := []string{upstreamID}
taskMap := map[string]*model.Task{upstreamID: task}
require.NoError(t, UpdateBatchTasks(context.Background(), adaptor, map[int][]string{channelID: taskIDs}, taskMap))
var persisted model.Task
require.NoError(t, model.DB.First(&persisted, task.ID).Error)
assert.Equal(t, testCase.status, persisted.Status)
assert.Equal(t, testCase.actualQuota, persisted.Quota)
var persistedData map[string]any
require.NoError(t, common.Unmarshal(persisted.Data, &persistedData))
assert.Equal(t, "must-be-preserved", persistedData["provider_payload"])
assert.Equal(t, initialQuota+(preConsumedQuota-testCase.actualQuota), getUserQuota(t, userID))
assert.Equal(t, tokenRemain+(preConsumedQuota-testCase.actualQuota), getTokenRemainQuota(t, tokenID))
assert.Equal(t, int64(1), countLogs(t))
if testCase.status == model.TaskStatusFailure {
log := getLastLog(t)
require.NotNil(t, log)
assert.Equal(t, model.LogTypeRefund, log.Type)
}
// A duplicate terminal response must not settle the same task twice.
require.NoError(t, UpdateBatchTasks(context.Background(), adaptor, map[int][]string{channelID: taskIDs}, taskMap))
assert.Equal(t, initialQuota+(preConsumedQuota-testCase.actualQuota), getUserQuota(t, userID))
assert.Equal(t, int64(1), countLogs(t))
})
}
}
func TestUpdateBatchTasksRefundsFailedTieredTask(t *testing.T) {
truncate(t)
const userID, tokenID, channelID = 43, 43, 143
const initialQuota, preConsumedQuota, tokenRemain = 10_000, 5_000, 8_000
seedUser(t, userID, initialQuota)
seedToken(t, tokenID, userID, "sk-batch-tiered-refund", tokenRemain)
seedTaskPollingChannel(t, channelID, true)
expression := `tier("actual", u("units"))`
task := makeTask(userID, channelID, preConsumedQuota, tokenID, BillingSourceWallet, 0)
task.TaskID = "task_batch_tiered_refund"
task.Platform = "batch-plugin"
task.PrivateData.UpstreamTaskID = "upstream_batch_tiered_refund"
task.PrivateData.BillingContext.TieredSnapshot = &billingexpr.BillingSnapshot{
ExprString: expression,
ExprHash: billingexpr.ExprHashString(expression),
GroupRatio: 1,
QuotaPerUnit: 1_000,
ExprVersion: 1,
TaskUsageBilling: true,
UsageFacts: map[string]any{"units": float64(5)},
EstimatedTier: "actual",
}
require.NoError(t, model.DB.Create(task).Error)
upstreamID := task.GetUpstreamTaskID()
adaptor := &batchPollingAdaptor{results: map[string]*BatchTaskResult{
upstreamID: {TaskInfo: relaycommon.TaskInfo{
TaskID: upstreamID,
Status: model.TaskStatusFailure,
Reason: "upstream failed",
UsageFacts: map[string]any{"units": float64(5)},
}},
}}
require.NoError(t, UpdateBatchTasks(context.Background(), adaptor, map[int][]string{channelID: {upstreamID}}, map[string]*model.Task{upstreamID: task}))
var persisted model.Task
require.NoError(t, model.DB.First(&persisted, task.ID).Error)
assert.EqualValues(t, model.TaskStatusFailure, persisted.Status)
assert.Zero(t, persisted.Quota)
assert.Equal(t, initialQuota+preConsumedQuota, getUserQuota(t, userID))
assert.Equal(t, tokenRemain+preConsumedQuota, getTokenRemainQuota(t, tokenID))
log := getLastLog(t)
require.NotNil(t, log)
assert.Equal(t, model.LogTypeRefund, log.Type)
assert.Equal(t, preConsumedQuota, log.Quota)
var other map[string]any
require.NoError(t, common.UnmarshalJsonStr(log.Other, &other))
assert.Equal(t, "tiered_expr", other["billing_mode"])
assert.Equal(t, "actual", other["matched_tier"])
facts, ok := other["usage_facts"].(map[string]any)
require.True(t, ok)
assert.Equal(t, map[string]any{"units": float64(5)}, facts)
}
func TestUpdateBatchTasksRefundsFailedTaskWithoutUsageSettlement(t *testing.T) {
truncate(t)
const userID, tokenID, channelID = 42, 42, 142
const initialQuota, preConsumedQuota, tokenRemain = 10_000, 4_000, 7_000
seedUser(t, userID, initialQuota)
seedToken(t, tokenID, userID, "sk-batch-refund", tokenRemain)
seedTaskPollingChannel(t, channelID, true)
task := makeTask(userID, channelID, preConsumedQuota, tokenID, BillingSourceWallet, 0)
task.TaskID = "task_batch_refund"
task.Platform = "batch-plugin"
task.Properties.OriginModelName = "missing-batch-token-price"
task.PrivateData.UpstreamTaskID = "upstream_batch_refund"
task.PrivateData.BillingContext.OriginModelName = "missing-batch-token-price"
require.NoError(t, model.DB.Create(task).Error)
upstreamID := task.GetUpstreamTaskID()
adaptor := &batchPollingAdaptor{results: map[string]*BatchTaskResult{
upstreamID: {TaskInfo: relaycommon.TaskInfo{TaskID: upstreamID, Status: model.TaskStatusFailure, Reason: "upstream failed", TotalTokens: 123}},
}}
require.NoError(t, UpdateBatchTasks(context.Background(), adaptor, map[int][]string{channelID: {upstreamID}}, map[string]*model.Task{upstreamID: task}))
assert.Equal(t, initialQuota+preConsumedQuota, getUserQuota(t, userID))
assert.Equal(t, tokenRemain+preConsumedQuota, getTokenRemainQuota(t, tokenID))
log := getLastLog(t)
require.NotNil(t, log)
assert.Equal(t, model.LogTypeRefund, log.Type)
}
func TestUpdateVideoTasksCanSkipPollingSleepPerChannel(t *testing.T) {
truncate(t)
const channelID = 102
seedTaskPollingChannel(t, channelID, true)
first := seedPollingTask(t, channelID, "task_public_3", "upstream_3")
second := seedPollingTask(t, channelID, "task_public_4", "upstream_4")
adaptor := &taskPollingFetchAdaptor{}
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return adaptor }
t.Cleanup(func() { GetTaskAdaptorFunc = previousFactory })
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
err := UpdateVideoTasks(ctx, constant.TaskPlatform("kling"), map[int][]string{
channelID: {
first.GetUpstreamTaskID(),
second.GetUpstreamTaskID(),
},
}, map[string]*model.Task{
first.GetUpstreamTaskID(): first,
second.GetUpstreamTaskID(): second,
})
require.NoError(t, err)
assert.Equal(t, 2, adaptor.fetchCount())
}
func TestUpdateVideoTasksDefaultSleepDoesNotBlockOtherChannels(t *testing.T) {
truncate(t)
const firstChannelID = 201
const secondChannelID = 202
seedTaskPollingChannel(t, firstChannelID, false)
seedTaskPollingChannel(t, secondChannelID, false)
firstChannelFirst := seedPollingTask(t, firstChannelID, "task_public_5", "upstream_a_1")
firstChannelSecond := seedPollingTask(t, firstChannelID, "task_public_6", "upstream_a_2")
secondChannelFirst := seedPollingTask(t, secondChannelID, "task_public_7", "upstream_b_1")
secondChannelSecond := seedPollingTask(t, secondChannelID, "task_public_8", "upstream_b_2")
adaptor := &taskPollingFetchAdaptor{}
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return adaptor }
t.Cleanup(func() { GetTaskAdaptorFunc = previousFactory })
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
err := UpdateVideoTasks(ctx, constant.TaskPlatform("kling"), map[int][]string{
firstChannelID: {
firstChannelFirst.GetUpstreamTaskID(),
firstChannelSecond.GetUpstreamTaskID(),
},
secondChannelID: {
secondChannelFirst.GetUpstreamTaskID(),
secondChannelSecond.GetUpstreamTaskID(),
},
}, map[string]*model.Task{
firstChannelFirst.GetUpstreamTaskID(): firstChannelFirst,
firstChannelSecond.GetUpstreamTaskID(): firstChannelSecond,
secondChannelFirst.GetUpstreamTaskID(): secondChannelFirst,
secondChannelSecond.GetUpstreamTaskID(): secondChannelSecond,
})
require.ErrorIs(t, err, context.DeadlineExceeded)
assert.ElementsMatch(t, []string{"upstream_a_1", "upstream_b_1"}, adaptor.fetchedTaskIDs())
}
func TestUpdateVideoTasksSlowChannelDoesNotBlockOtherChannels(t *testing.T) {
truncate(t)
const slowChannelID = 251
const fastChannelID = 252
seedTaskPollingChannel(t, slowChannelID, false)
seedTaskPollingChannel(t, fastChannelID, true)
slowTask := seedPollingTask(t, slowChannelID, "task_public_slow", "upstream_slow_1")
fastFirst := seedPollingTask(t, fastChannelID, "task_public_fast_1", "upstream_fast_parallel_1")
fastSecond := seedPollingTask(t, fastChannelID, "task_public_fast_2", "upstream_fast_parallel_2")
adaptor := &taskPollingFetchAdaptor{
fetched: make(chan string, 4),
blockTaskID: slowTask.GetUpstreamTaskID(),
blockStarted: make(chan struct{}),
releaseBlock: make(chan struct{}),
}
var releaseOnce sync.Once
releaseBlockedTask := func() {
releaseOnce.Do(func() {
close(adaptor.releaseBlock)
})
}
t.Cleanup(releaseBlockedTask)
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return adaptor }
t.Cleanup(func() { GetTaskAdaptorFunc = previousFactory })
errCh := make(chan error, 1)
gopool.Go(func() {
errCh <- UpdateVideoTasks(context.Background(), constant.TaskPlatform("kling"), map[int][]string{
slowChannelID: {
slowTask.GetUpstreamTaskID(),
},
fastChannelID: {
fastFirst.GetUpstreamTaskID(),
fastSecond.GetUpstreamTaskID(),
},
}, map[string]*model.Task{
slowTask.GetUpstreamTaskID(): slowTask,
fastFirst.GetUpstreamTaskID(): fastFirst,
fastSecond.GetUpstreamTaskID(): fastSecond,
})
})
select {
case <-adaptor.blockStarted:
case <-time.After(500 * time.Millisecond):
t.Fatal("slow channel did not start blocking")
}
require.Eventually(t, func() bool {
fetchedTaskIDs := adaptor.fetchedTaskIDs()
return len(fetchedTaskIDs) == 2 &&
fetchedTaskIDs[0] == fastFirst.GetUpstreamTaskID() &&
fetchedTaskIDs[1] == fastSecond.GetUpstreamTaskID()
}, 500*time.Millisecond, 10*time.Millisecond)
releaseBlockedTask()
require.NoError(t, <-errCh)
assert.ElementsMatch(t, []string{
slowTask.GetUpstreamTaskID(),
fastFirst.GetUpstreamTaskID(),
fastSecond.GetUpstreamTaskID(),
}, adaptor.fetchedTaskIDs())
}
func TestUpdateVideoTasksMixedChannelSleepSettings(t *testing.T) {
truncate(t)
const sleepyChannelID = 301
const fastChannelID = 302
seedTaskPollingChannel(t, sleepyChannelID, false)
seedTaskPollingChannel(t, fastChannelID, true)
sleepyFirst := seedPollingTask(t, sleepyChannelID, "task_public_9", "upstream_sleepy_1")
sleepySecond := seedPollingTask(t, sleepyChannelID, "task_public_10", "upstream_sleepy_2")
fastFirst := seedPollingTask(t, fastChannelID, "task_public_11", "upstream_fast_1")
fastSecond := seedPollingTask(t, fastChannelID, "task_public_12", "upstream_fast_2")
adaptor := &taskPollingFetchAdaptor{}
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return adaptor }
t.Cleanup(func() { GetTaskAdaptorFunc = previousFactory })
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := UpdateVideoTasks(ctx, constant.TaskPlatform("kling"), map[int][]string{
sleepyChannelID: {
sleepyFirst.GetUpstreamTaskID(),
sleepySecond.GetUpstreamTaskID(),
},
fastChannelID: {
fastFirst.GetUpstreamTaskID(),
fastSecond.GetUpstreamTaskID(),
},
}, map[string]*model.Task{
sleepyFirst.GetUpstreamTaskID(): sleepyFirst,
sleepySecond.GetUpstreamTaskID(): sleepySecond,
fastFirst.GetUpstreamTaskID(): fastFirst,
fastSecond.GetUpstreamTaskID(): fastSecond,
})
require.ErrorIs(t, err, context.DeadlineExceeded)
assert.ElementsMatch(t, []string{"upstream_sleepy_1", "upstream_fast_1", "upstream_fast_2"}, adaptor.fetchedTaskIDs())
}
func TestUpdateSunoTasksStalePollsRefundExactlyOnce(t *testing.T) {
truncate(t)
const userID, tokenID, channelID = 401, 401, 401
const initialUserQuota, initialTokenQuota, taskQuota = 10_000, 6_000, 2_500
const publicTaskID, upstreamTaskID = "suno_public_refund_once", "suno_upstream_refund_once"
seedUser(t, userID, initialUserQuota)
seedToken(t, tokenID, userID, "sk-suno-refund-once", initialTokenQuota)
baseURL := "https://suno.invalid"
require.NoError(t, model.DB.Create(&model.Channel{
Id: channelID,
Type: constant.ChannelTypeSunoAPI,
Name: "suno_refund_once",
Key: "sk-suno-channel",
Status: common.ChannelStatusEnabled,
BaseURL: &baseURL,
}).Error)
task := makeTask(userID, channelID, taskQuota, tokenID, BillingSourceWallet, 0)
task.TaskID = publicTaskID
task.Platform = constant.TaskPlatformSuno
task.Status = model.TaskStatusInProgress
task.Progress = "50%"
task.SubmitTime = time.Now().Unix()
task.PrivateData.UpstreamTaskID = upstreamTaskID
require.NoError(t, model.DB.Create(task).Error)
var firstPollTask model.Task
var staleSecondPollTask model.Task
require.NoError(t, model.DB.First(&firstPollTask, task.ID).Error)
require.NoError(t, model.DB.First(&staleSecondPollTask, task.ID).Error)
adaptor := &batchPollingAdaptor{results: map[string]*BatchTaskResult{
upstreamTaskID: {TaskInfo: relaycommon.TaskInfo{
TaskID: upstreamTaskID,
Status: model.TaskStatusFailure,
Reason: "upstream failed",
}},
}}
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return adaptor }
t.Cleanup(func() { GetTaskAdaptorFunc = previousFactory })
require.NoError(t, updateBatchTasks(context.Background(), adaptor, channelID, []string{upstreamTaskID}, map[string]*model.Task{
upstreamTaskID: &firstPollTask,
}))
require.NoError(t, updateBatchTasks(context.Background(), adaptor, channelID, []string{upstreamTaskID}, map[string]*model.Task{
upstreamTaskID: &staleSecondPollTask,
}))
var reloaded model.Task
require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status)
assert.Zero(t, reloaded.Quota)
assert.Equal(t, initialUserQuota+taskQuota, getUserQuota(t, userID))
assert.Equal(t, initialTokenQuota+taskQuota, getTokenRemainQuota(t, tokenID))
assert.Equal(t, int64(1), countLogs(t))
}
func TestRunTaskPollingOnceDoesNotRefundHistoricalFailedTask(t *testing.T) {
truncate(t)
const userID, initialQuota, taskQuota = 402, 10_000, 1_200
seedUser(t, userID, initialQuota)
task := makeTask(userID, 0, taskQuota, 0, BillingSourceWallet, 0)
task.TaskID = "historical_failed_already_refunded"
task.Status = model.TaskStatusFailure
task.Progress = "100%"
task.SubmitTime = time.Now().Add(-90 * 24 * time.Hour).Unix()
task.UpdatedAt = time.Now().Add(-time.Minute).Unix()
require.NoError(t, model.DB.Create(task).Error)
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor {
return &taskPollingFetchAdaptor{}
}
t.Cleanup(func() { GetTaskAdaptorFunc = previousFactory })
summary := RunTaskPollingOnce(context.Background(), nil)
assert.Zero(t, summary.UnfinishedTasks)
assert.Equal(t, initialQuota, getUserQuota(t, userID))
assert.Equal(t, taskQuota, getTaskQuota(t, task.ID))
assert.Equal(t, int64(0), countLogs(t))
}
func TestSweepTimedOutTasksHonorsRefundRolloutBoundary(t *testing.T) {
truncate(t)
const (
userID = 403
initialQuota = 10_000
legacyTaskQuota = 1_800
modernTaskQuota = 1_200
)
seedUser(t, userID, initialQuota)
legacyTask := makeTask(userID, 0, legacyTaskQuota, 0, BillingSourceWallet, 0)
legacyTask.TaskID = "legacy_timeout_without_refund"
legacyTask.Progress = "50%"
legacyTask.SubmitTime = 1771718399 // 2026-02-21 23:59:59 UTC
require.NoError(t, model.DB.Create(legacyTask).Error)
modernTask := makeTask(userID, 0, modernTaskQuota, 0, BillingSourceWallet, 0)
modernTask.TaskID = "modern_timeout_with_refund"
modernTask.Progress = "50%"
modernTask.SubmitTime = 1771718400 // 2026-02-22 00:00:00 UTC
require.NoError(t, model.DB.Create(modernTask).Error)
previousTimeout := constant.TaskTimeoutMinutes
constant.TaskTimeoutMinutes = 1
t.Cleanup(func() { constant.TaskTimeoutMinutes = previousTimeout })
sweepTimedOutTasks(context.Background())
var reloadedLegacy model.Task
var reloadedModern model.Task
require.NoError(t, model.DB.First(&reloadedLegacy, legacyTask.ID).Error)
require.NoError(t, model.DB.First(&reloadedModern, modernTask.ID).Error)
assert.EqualValues(t, model.TaskStatusFailure, reloadedLegacy.Status)
assert.EqualValues(t, model.TaskStatusFailure, reloadedModern.Status)
assert.Zero(t, reloadedLegacy.Quota)
assert.Zero(t, reloadedModern.Quota)
assert.Contains(t, reloadedLegacy.FailReason, "旧系统遗留任务")
assert.Contains(t, reloadedModern.FailReason, "任务超时")
assert.Equal(t, initialQuota+modernTaskQuota, getUserQuota(t, userID))
assert.Equal(t, int64(1), countLogs(t))
}