Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion controller/system_task_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ type asyncTaskPollHandler struct{}
func (asyncTaskPollHandler) Type() string { return model.SystemTaskTypeAsyncTaskPoll }

func (asyncTaskPollHandler) Enabled() bool {
return constant.UpdateTask && model.HasUnfinishedSyncTasks()
return constant.UpdateTask && model.HasTaskPollingWork()
}

func (asyncTaskPollHandler) Interval() time.Duration { return 15 * time.Second }
Expand Down
81 changes: 80 additions & 1 deletion model/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ const (
TaskStatusUnknown = "UNKNOWN"
)

// TaskRefundLegacyCutoff separates legacy timeout tasks that intentionally
// do not receive automatic refunds from tasks covered by reconciliation.
const TaskRefundLegacyCutoff int64 = 1740182400 // 2025-02-22 00:00:00 UTC

type Task struct {
ID int64 `json:"id" gorm:"primary_key;AUTO_INCREMENT"`
CreatedAt int64 `json:"created_at" gorm:"index"`
Expand Down Expand Up @@ -304,6 +308,28 @@ func GetTimedOutUnfinishedTasks(cutoffUnix int64, limit int) []*Task {
return tasks
}

// GetUnrefundedFailedTasks returns failed tasks whose non-zero quota marks a
// pending refund. Legacy timeout tasks are excluded before LIMIT is applied so
// they cannot starve refundable tasks from the reconciliation sweep.
func GetUnrefundedFailedTasks(updatedBefore int64, limit int) []*Task {
if limit <= 0 {
return nil
}

var tasks []*Task
err := DB.Where("status = ?", TaskStatusFailure).
Where("quota != ?", 0).
Where("updated_at <= ?", updatedBefore).
Where("(submit_time <= ? OR submit_time >= ?)", 0, TaskRefundLegacyCutoff).
Order("id").
Limit(limit).
Find(&tasks).Error
if err != nil {
return nil
}
return tasks
}

func GetAllUnFinishSyncTasks(limit int) []*Task {
var tasks []*Task
var err error
Expand All @@ -330,6 +356,24 @@ func HasUnfinishedSyncTasks() bool {
return err == nil && id != 0
}

// HasTaskPollingWork reports whether polling has either an unfinished task or
// a failed task with a pending, non-legacy refund. The latter keeps the system
// task scheduler active when reconciliation is the only work left.
func HasTaskPollingWork() bool {
if HasUnfinishedSyncTasks() {
return true
}

var id int64
err := DB.Model(&Task{}).
Where("status = ?", TaskStatusFailure).
Where("quota != ?", 0).
Where("(submit_time <= ? OR submit_time >= ?)", 0, TaskRefundLegacyCutoff).
Limit(1).
Pluck("id", &id).Error
return err == nil && id != 0
}

func GetByOnlyTaskId(taskId string) (*Task, bool, error) {
if taskId == "" {
return nil, false, nil
Expand Down Expand Up @@ -421,9 +465,44 @@ func (t *Task) UpdateQuota() error {
return DB.Model(t).Update("quota", t.Quota).Error
}

// ClaimQuotaForRefund atomically clears an expected non-zero quota. A true
// result grants the caller ownership of the corresponding refund attempt.
func ClaimQuotaForRefund(id int64, expectedQuota int) (bool, error) {
if expectedQuota == 0 {
return false, nil
}

result := DB.Model(&Task{}).
Where("id = ? AND quota = ?", id, expectedQuota).
Update("quota", 0)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}

// RestoreQuotaAfterFailedRefund restores a claimed quota marker only while it
// is still zero. It is used when the observable funding adjustment fails, so a
// later reconciliation pass can retry without overwriting another writer.
func RestoreQuotaAfterFailedRefund(id int64, quota int) (bool, error) {
if quota == 0 {
return false, nil
}

result := DB.Model(&Task{}).
Where("id = ? AND quota = ?", id, 0).
Update("quota", quota)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}

// UpdateWithStatus performs a conditional UPDATE guarded by fromStatus (CAS).
// Returns (true, nil) if this caller won the update, (false, nil) if
// another process already moved the task out of fromStatus.
// another process already moved the task out of fromStatus. MySQL commonly
// reports changed rows rather than matched rows, so a same-value no-op update
// can also return false even when the status predicate still matched.
//
// Uses Model().Select("*").Updates() instead of Save() because GORM's Save
// falls back to INSERT ON CONFLICT when the WHERE-guarded UPDATE matches
Expand Down
105 changes: 105 additions & 0 deletions model/task_cas_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,108 @@ func TestUpdateWithStatus_ConcurrentWinner(t *testing.T) {
}
assert.Equal(t, 1, winCount, "exactly one goroutine should win the CAS")
}

func TestClaimQuotaForRefund_OnlyOneClaimSucceeds(t *testing.T) {
truncateTables(t)

task := &Task{
TaskID: "task_refund_claim",
Status: TaskStatusFailure,
Quota: 1000,
Data: json.RawMessage(`{}`),
}
insertTask(t, task)

claimed, err := ClaimQuotaForRefund(task.ID, task.Quota)
require.NoError(t, err)
assert.True(t, claimed)

claimed, err = ClaimQuotaForRefund(task.ID, task.Quota)
require.NoError(t, err)
assert.False(t, claimed)

var reloaded Task
require.NoError(t, DB.First(&reloaded, task.ID).Error)
assert.Zero(t, reloaded.Quota)
}

func TestGetUnrefundedFailedTasks_FiltersAndLimits(t *testing.T) {
truncateTables(t)

tasks := []*Task{
{TaskID: "failed_refundable_1", Status: TaskStatusFailure, Quota: 100, SubmitTime: TaskRefundLegacyCutoff, Data: json.RawMessage(`{}`)},
{TaskID: "failed_refundable_2", Status: TaskStatusFailure, Quota: 200, SubmitTime: TaskRefundLegacyCutoff + 1, Data: json.RawMessage(`{}`)},
{TaskID: "legacy_failed", Status: TaskStatusFailure, Quota: 400, SubmitTime: TaskRefundLegacyCutoff - 1, Data: json.RawMessage(`{}`)},
{TaskID: "failed_without_quota", Status: TaskStatusFailure, Quota: 0, Data: json.RawMessage(`{}`)},
{TaskID: "successful_with_quota", Status: TaskStatusSuccess, Quota: 300, Data: json.RawMessage(`{}`)},
}
for _, task := range tasks {
insertTask(t, task)
}

updatedBefore := time.Now().Unix() + 1
found := GetUnrefundedFailedTasks(updatedBefore, 1)
require.Len(t, found, 1)
assert.Equal(t, tasks[0].ID, found[0].ID)

found = GetUnrefundedFailedTasks(updatedBefore, 10)
require.Len(t, found, 2)
assert.Equal(t, []int64{tasks[0].ID, tasks[1].ID}, []int64{found[0].ID, found[1].ID})

assert.Empty(t, GetUnrefundedFailedTasks(updatedBefore, 0))
}

func TestRestoreQuotaAfterFailedRefund_OnlyRestoresClaimedMarker(t *testing.T) {
truncateTables(t)

task := &Task{
TaskID: "task_refund_restore",
Status: TaskStatusFailure,
Quota: 750,
Data: json.RawMessage(`{}`),
}
insertTask(t, task)

claimed, err := ClaimQuotaForRefund(task.ID, task.Quota)
require.NoError(t, err)
require.True(t, claimed)

restored, err := RestoreQuotaAfterFailedRefund(task.ID, task.Quota)
require.NoError(t, err)
assert.True(t, restored)

restored, err = RestoreQuotaAfterFailedRefund(task.ID, task.Quota)
require.NoError(t, err)
assert.False(t, restored)

var reloaded Task
require.NoError(t, DB.First(&reloaded, task.ID).Error)
assert.Equal(t, task.Quota, reloaded.Quota)
}

func TestHasTaskPollingWork_IncludesOnlyRefundableFailedTasks(t *testing.T) {
truncateTables(t)
assert.False(t, HasTaskPollingWork())

legacy := &Task{
TaskID: "legacy_failed_work",
Status: TaskStatusFailure,
Progress: "100%",
Quota: 500,
SubmitTime: TaskRefundLegacyCutoff - 1,
Data: json.RawMessage(`{}`),
}
insertTask(t, legacy)
assert.False(t, HasTaskPollingWork())

refundable := &Task{
TaskID: "refundable_failed_work",
Status: TaskStatusFailure,
Progress: "100%",
Quota: 500,
SubmitTime: TaskRefundLegacyCutoff,
Data: json.RawMessage(`{}`),
}
insertTask(t, refundable)
assert.True(t, HasTaskPollingWork())
}
15 changes: 12 additions & 3 deletions service/task_billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,16 +162,17 @@ func taskModelName(task *model.Task) string {

// RefundTaskQuota 统一的任务失败退款逻辑。
// 当异步任务失败时,将预扣的 quota 退还给用户(支持钱包和订阅),并退还令牌额度。
func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) {
// 返回资金来源是否已成功退还;失败时保留 quota 作为后续对账标记。
func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) bool {
quota := task.Quota
if quota == 0 {
return
return true
}

// 1. 退还资金来源(钱包或订阅)
if err := taskAdjustFunding(task, -quota); err != nil {
logger.LogWarn(ctx, fmt.Sprintf("退还资金来源失败 task %s: %s", task.TaskID, err.Error()))
return
return false
}

// 2. 退还令牌额度
Expand All @@ -192,6 +193,14 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) {
Group: task.Group,
Other: other,
})

// 4. 资金退款完成后再清除持久化标记;失败时保留非零 quota,
// 由后续对账重试。回写失败必须显式告警,避免漏掉潜在的重复退款风险。
task.Quota = 0
if err := task.UpdateQuota(); err != nil {
logger.LogError(ctx, fmt.Sprintf("退款成功但清除 task quota 失败 task %s: %s", task.TaskID, err.Error()))
}
return true
}

// RecalculateTaskQuota 通用的异步差额结算。
Expand Down
39 changes: 35 additions & 4 deletions service/task_billing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,13 @@ func getSubscriptionUsed(t *testing.T, id int) int64 {
return sub.AmountUsed
}

func getTaskQuota(t *testing.T, id int64) int {
t.Helper()
var task model.Task
require.NoError(t, model.DB.Select("quota").Where("id = ?", id).First(&task).Error)
return task.Quota
}

func getLastLog(t *testing.T) *model.Log {
t.Helper()
var log model.Log
Expand Down Expand Up @@ -304,8 +311,9 @@ func TestRefundTaskQuota_Wallet(t *testing.T) {
seedChannel(t, channelID)

task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
require.NoError(t, model.DB.Create(task).Error)

RefundTaskQuota(ctx, task, "task failed: upstream error")
assert.True(t, RefundTaskQuota(ctx, task, "task failed: upstream error"))

// User quota should increase by preConsumed
assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
Expand All @@ -320,6 +328,8 @@ func TestRefundTaskQuota_Wallet(t *testing.T) {
assert.Equal(t, model.LogTypeRefund, log.Type)
assert.Equal(t, preConsumed, log.Quota)
assert.Equal(t, "test-model", log.ModelName)
assert.Zero(t, task.Quota)
assert.Zero(t, getTaskQuota(t, task.ID))
}

func TestRefundTaskQuota_Subscription(t *testing.T) {
Expand All @@ -337,8 +347,9 @@ func TestRefundTaskQuota_Subscription(t *testing.T) {
seedSubscription(t, subID, userID, subTotal, subUsed)

task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceSubscription, subID)
require.NoError(t, model.DB.Create(task).Error)

RefundTaskQuota(ctx, task, "subscription task failed")
assert.True(t, RefundTaskQuota(ctx, task, "subscription task failed"))

// Subscription used should decrease by preConsumed
assert.Equal(t, subUsed-int64(preConsumed), getSubscriptionUsed(t, subID))
Expand All @@ -349,6 +360,7 @@ func TestRefundTaskQuota_Subscription(t *testing.T) {
log := getLastLog(t)
require.NotNil(t, log)
assert.Equal(t, model.LogTypeRefund, log.Type)
assert.Zero(t, getTaskQuota(t, task.ID))
}

func TestRefundTaskQuota_ZeroQuota(t *testing.T) {
Expand All @@ -360,7 +372,7 @@ func TestRefundTaskQuota_ZeroQuota(t *testing.T) {

task := makeTask(userID, 0, 0, 0, BillingSourceWallet, 0)

RefundTaskQuota(ctx, task, "zero quota task")
assert.True(t, RefundTaskQuota(ctx, task, "zero quota task"))

// No change to user quota
assert.Equal(t, 5000, getUserQuota(t, userID))
Expand All @@ -380,8 +392,9 @@ func TestRefundTaskQuota_NoToken(t *testing.T) {
seedChannel(t, channelID)

task := makeTask(userID, channelID, preConsumed, 0, BillingSourceWallet, 0) // TokenId=0
require.NoError(t, model.DB.Create(task).Error)

RefundTaskQuota(ctx, task, "no token task failed")
assert.True(t, RefundTaskQuota(ctx, task, "no token task failed"))

// User quota refunded
assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
Expand All @@ -390,6 +403,23 @@ func TestRefundTaskQuota_NoToken(t *testing.T) {
log := getLastLog(t)
require.NotNil(t, log)
assert.Equal(t, model.LogTypeRefund, log.Type)
assert.Zero(t, getTaskQuota(t, task.ID))
}

func TestRefundTaskQuota_FundingFailureKeepsPendingMarker(t *testing.T) {
truncate(t)
ctx := context.Background()

const userID, preConsumed = 5, 1200
seedUser(t, userID, 5000)
task := makeTask(userID, 0, preConsumed, 0, BillingSourceSubscription, 9999)
task.Status = model.TaskStatusFailure
require.NoError(t, model.DB.Create(task).Error)

assert.False(t, RefundTaskQuota(ctx, task, "subscription missing"))
assert.Equal(t, preConsumed, task.Quota)
assert.Equal(t, preConsumed, getTaskQuota(t, task.ID))
assert.Equal(t, int64(0), countLogs(t))
}

// ===========================================================================
Expand Down Expand Up @@ -608,6 +638,7 @@ func TestCASGuardedRefund_Win(t *testing.T) {
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)

// Refund should have happened
assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
Expand Down
Loading