Skip to content

Commit 0ed96ae

Browse files
feat: prioritize premium 5h recovery windows (port upstream 4a48799)
1 parent 8732f06 commit 0ed96ae

10 files changed

Lines changed: 237 additions & 10 deletions

File tree

admin/handler.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ type schedulerBreakdownResponse struct {
396396
FailurePenalty float64 `json:"failure_penalty"`
397397
SuccessBonus float64 `json:"success_bonus"`
398398
UsagePenalty7d float64 `json:"usage_penalty_7d"`
399+
UsageUrgencyBonus5h float64 `json:"usage_urgency_bonus_5h"`
399400
LatencyPenalty float64 `json:"latency_penalty"`
400401
SuccessRatePenalty float64 `json:"success_rate_penalty"`
401402
}
@@ -472,6 +473,7 @@ func (h *Handler) ListAccounts(c *gin.Context) {
472473
FailurePenalty: debug.Breakdown.FailurePenalty,
473474
SuccessBonus: debug.Breakdown.SuccessBonus,
474475
UsagePenalty7d: debug.Breakdown.UsagePenalty7d,
476+
UsageUrgencyBonus5h: debug.Breakdown.UsageUrgencyBonus5h,
475477
LatencyPenalty: debug.Breakdown.LatencyPenalty,
476478
SuccessRatePenalty: debug.Breakdown.SuccessRatePenalty,
477479
}

auth/fast_scheduler.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package auth
22

33
import (
4+
"math"
45
"sort"
56
"sync"
67
"sync/atomic"
@@ -237,8 +238,8 @@ func (s *FastScheduler) scanRangeLocked(expectedTier AccountHealthTier, rangeSta
237238
if exclude != nil && exclude[entry.dbID] {
238239
continue
239240
}
240-
tier, _, limit, _, available := entry.acc.fastSchedulerSnapshot(baseLimit, now)
241-
if tier != expectedTier {
241+
tier, dispatchScore, limit, proven, available := entry.acc.fastSchedulerSnapshot(baseLimit, now)
242+
if tier != expectedTier || proven != entry.proven || math.Abs(dispatchScore-entry.dispatchScore) >= 1 {
242243
s.removeLocked(entry.dbID)
243244
if available && limit > 0 {
244245
s.insertLocked(entry.acc, now)

auth/fast_scheduler_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,3 +466,65 @@ func TestFastSchedulerRelease(t *testing.T) {
466466
t.Fatalf("ActiveRequests after Release() = %d, want 0", got)
467467
}
468468
}
469+
470+
func TestFastSchedulerPrefersPremium5hResetSoonWithinTier(t *testing.T) {
471+
now := time.Now()
472+
later := newFastSchedulerTestAccount(1, HealthTierHealthy, 150, 1)
473+
later.PlanType = "plus"
474+
later.UsagePercent5h = 25
475+
later.UsagePercent5hValid = true
476+
later.Reset5hAt = now.Add(5 * time.Hour)
477+
478+
soon := newFastSchedulerTestAccount(2, HealthTierHealthy, 150, 1)
479+
soon.PlanType = "plus"
480+
soon.UsagePercent5h = 25
481+
soon.UsagePercent5hValid = true
482+
soon.Reset5hAt = now.Add(30 * time.Minute)
483+
484+
scheduler := NewFastScheduler(1)
485+
scheduler.Rebuild([]*Account{later, soon})
486+
487+
got := scheduler.Acquire()
488+
if got == nil {
489+
t.Fatal("Acquire() returned nil")
490+
}
491+
defer scheduler.Release(got)
492+
493+
if got.DBID != soon.DBID {
494+
t.Fatalf("Acquire() picked dbID=%d, want reset-soon account %d", got.DBID, soon.DBID)
495+
}
496+
}
497+
498+
func TestPersistUsageSnapshot5hOnlyUpdatesFastSchedulerPriority(t *testing.T) {
499+
now := time.Now()
500+
later := newFastSchedulerTestAccount(1, HealthTierHealthy, 150, 1)
501+
later.PlanType = "plus"
502+
later.UsagePercent5h = 25
503+
later.UsagePercent5hValid = true
504+
later.Reset5hAt = now.Add(5 * time.Hour)
505+
506+
soon := newFastSchedulerTestAccount(2, HealthTierHealthy, 150, 1)
507+
soon.PlanType = "plus"
508+
soon.UsagePercent5h = 25
509+
soon.UsagePercent5hValid = true
510+
soon.Reset5hAt = now.Add(5 * time.Hour)
511+
512+
store := &Store{
513+
accounts: []*Account{later, soon},
514+
maxConcurrency: 1,
515+
}
516+
store.SetFastSchedulerEnabled(true)
517+
518+
soon.SetUsageSnapshot5h(25, time.Now().Add(30*time.Minute))
519+
store.PersistUsageSnapshot5hOnly(soon)
520+
521+
got := store.Next()
522+
if got == nil {
523+
t.Fatal("Next() returned nil")
524+
}
525+
defer store.Release(got)
526+
527+
if got.DBID != soon.DBID {
528+
t.Fatalf("Next() picked dbID=%d, want reset-soon account %d", got.DBID, soon.DBID)
529+
}
530+
}

auth/premium_rate_limit.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ func (a *Account) GetUsageSnapshot5h() (pct float64, resetAt time.Time, ok bool)
7777

7878
// PersistUsageSnapshot5hOnly 持久化仅包含 5h 数据的用量快照。
7979
func (s *Store) PersistUsageSnapshot5hOnly(acc *Account) {
80-
if acc == nil || s == nil || s.db == nil {
80+
if acc == nil || s == nil {
8181
return
8282
}
8383

@@ -91,6 +91,12 @@ func (s *Store) PersistUsageSnapshot5hOnly(acc *Account) {
9191
acc.UsageUpdatedAt = updatedAt
9292
acc.mu.Unlock()
9393

94+
s.fastSchedulerUpdate(acc)
95+
96+
if s.db == nil {
97+
return
98+
}
99+
94100
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
95101
defer cancel()
96102
if err := s.db.UpdateUsageSnapshot5h(ctx, acc.DBID, pct5h, reset5hAt, updatedAt); err != nil {

auth/store.go

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ const (
105105
defaultBackgroundRefreshInterval = 2 * time.Minute
106106
defaultUsageProbeMaxAge = 10 * time.Minute
107107
defaultRecoveryProbeInterval = 30 * time.Minute
108+
premium5hUrgencyWindow = 4 * time.Hour
109+
premium5hUrgencyMaxBonus = 25.0
110+
premium5hUrgencyMinRemainingPct = 5.0
111+
premium5hUrgencyFullRemainingPct = 50.0
108112
)
109113

110114
// SchedulerBreakdown 调度评分拆解
@@ -117,6 +121,7 @@ type SchedulerBreakdown struct {
117121
SuccessBonus float64
118122
ProvenBonus float64 // 经过验证的账号(TotalRequests > 10)加分
119123
UsagePenalty7d float64
124+
UsageUrgencyBonus5h float64 // Premium 5h 重置窗口前的紧迫性 bonus
120125
LatencyPenalty float64
121126
SuccessRatePenalty float64 // 滑动窗口成功率惩罚
122127
}
@@ -346,8 +351,7 @@ func linearDecay(base float64, elapsed, window time.Duration) float64 {
346351
return base * (1.0 - float64(elapsed)/float64(window))
347352
}
348353

349-
func (a *Account) schedulerBreakdownLocked() SchedulerBreakdown {
350-
now := time.Now()
354+
func (a *Account) schedulerBreakdownLocked(now time.Time) SchedulerBreakdown {
351355
breakdown := SchedulerBreakdown{}
352356
premium5hLimited := a.premium5hRateLimitedLocked(now)
353357

@@ -419,6 +423,52 @@ func (a *Account) schedulerBreakdownLocked() SchedulerBreakdown {
419423
return breakdown
420424
}
421425

426+
// premium5hUsageUrgencyBonusLocked 计算 plus/pro 账号在 5h 重置窗口前的紧迫性 bonus。
427+
// 当账号还有较多剩余配额且即将到达重置点(剩余 ≤ 4h)时,提升 dispatch score
428+
// 让调度器优先把它的额度消耗掉,避免到点重置时白白浪费。
429+
// 需持有 a.mu 锁。
430+
func (a *Account) premium5hUsageUrgencyBonusLocked(now time.Time) float64 {
431+
if !isPremium5hPlan(a.PlanType) {
432+
return 0
433+
}
434+
if !a.UsagePercent5hValid || a.Reset5hAt.IsZero() {
435+
return 0
436+
}
437+
if a.UsagePercent5h >= 100 || a.premium5hRateLimitedLocked(now) {
438+
return 0
439+
}
440+
if a.AccessToken == "" || a.Status == StatusError || a.HealthTier == HealthTierBanned {
441+
return 0
442+
}
443+
if a.Status == StatusCooldown && now.Before(a.CooldownUtil) {
444+
return 0
445+
}
446+
if a.usageExhaustedLocked() {
447+
return 0
448+
}
449+
450+
timeRemaining := a.Reset5hAt.Sub(now)
451+
if timeRemaining <= 0 || timeRemaining > premium5hUrgencyWindow {
452+
return 0
453+
}
454+
455+
quotaRemaining := 100 - a.UsagePercent5h
456+
if quotaRemaining <= premium5hUrgencyMinRemainingPct {
457+
return 0
458+
}
459+
460+
timeFactor := 1 - float64(timeRemaining)/float64(premium5hUrgencyWindow)
461+
quotaFactor := quotaRemaining / premium5hUrgencyFullRemainingPct
462+
if quotaFactor > 1 {
463+
quotaFactor = 1
464+
}
465+
if quotaFactor < 0 {
466+
quotaFactor = 0
467+
}
468+
469+
return premium5hUrgencyMaxBonus * timeFactor * quotaFactor
470+
}
471+
422472
func (a *Account) effectiveBaseConcurrencyLocked(storeBaseLimit int64) int64 {
423473
if a.BaseConcurrencyOverride != nil && *a.BaseConcurrencyOverride > 0 {
424474
return *a.BaseConcurrencyOverride
@@ -463,7 +513,7 @@ func (a *Account) effectiveScoreBiasLocked(now time.Time, tier AccountHealthTier
463513

464514
func (a *Account) recomputeSchedulerLocked(baseLimit int64) {
465515
now := time.Now()
466-
breakdown := a.schedulerBreakdownLocked()
516+
breakdown := a.schedulerBreakdownLocked(now)
467517
score := 100.0 -
468518
breakdown.UnauthorizedPenalty -
469519
breakdown.RateLimitPenalty -
@@ -507,7 +557,10 @@ func (a *Account) recomputeSchedulerLocked(baseLimit int64) {
507557

508558
baseConcurrencyEffective := a.effectiveBaseConcurrencyLocked(baseLimit)
509559
scoreBiasEffective := a.effectiveScoreBiasLocked(now, tier)
510-
dispatchScore := score + float64(scoreBiasEffective)
560+
if a.dispatchBonusEligibleLocked(now, tier) {
561+
breakdown.UsageUrgencyBonus5h = a.premium5hUsageUrgencyBonusLocked(now)
562+
}
563+
dispatchScore := score + float64(scoreBiasEffective) + breakdown.UsageUrgencyBonus5h
511564

512565
a.HealthTier = tier
513566
a.SchedulerScore = score
@@ -859,6 +912,11 @@ func (a *Account) GetSchedulerDebugSnapshot(baseLimit int64) SchedulerDebugSnaps
859912
defer a.mu.Unlock()
860913

861914
a.recomputeSchedulerLocked(baseLimit)
915+
now := time.Now()
916+
breakdown := a.schedulerBreakdownLocked(now)
917+
if a.dispatchBonusEligibleLocked(now, a.HealthTier) {
918+
breakdown.UsageUrgencyBonus5h = a.premium5hUsageUrgencyBonusLocked(now)
919+
}
862920
return SchedulerDebugSnapshot{
863921
HealthTier: string(a.HealthTier),
864922
SchedulerScore: a.SchedulerScore,
@@ -868,7 +926,7 @@ func (a *Account) GetSchedulerDebugSnapshot(baseLimit int64) SchedulerDebugSnaps
868926
BaseConcurrencyOverride: cloneInt64Ptr(a.BaseConcurrencyOverride),
869927
BaseConcurrencyEffective: a.BaseConcurrencyEffective,
870928
DynamicConcurrencyLimit: a.DynamicConcurrencyLimit,
871-
Breakdown: a.schedulerBreakdownLocked(),
929+
Breakdown: breakdown,
872930
LastUnauthorizedAt: a.LastUnauthorizedAt,
873931
LastRateLimitedAt: a.LastRateLimitedAt,
874932
LastTimeoutAt: a.LastTimeoutAt,
@@ -2295,6 +2353,7 @@ func (s *Store) PersistUsageSnapshot(acc *Account, pct7d float64) {
22952353

22962354
now := time.Now()
22972355
acc.SetUsageSnapshot(pct7d, now)
2356+
s.fastSchedulerUpdate(acc)
22982357

22992358
if s.db == nil {
23002359
return

auth/store_scheduler_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,93 @@ func TestStoreNextPrefersHigherDispatchScoreWithinTier(t *testing.T) {
180180
t.Fatalf("Next() picked dbID=%d, want premium account %d", got.DBID, premium.DBID)
181181
}
182182
}
183+
184+
func TestAccountPremium5hUrgencyBonusOnlyAffectsDispatchScore(t *testing.T) {
185+
acc := &Account{
186+
DBID: 1,
187+
AccessToken: "token",
188+
Status: StatusReady,
189+
PlanType: "plus",
190+
UsagePercent5h: 20,
191+
UsagePercent5hValid: true,
192+
Reset5hAt: time.Now().Add(30 * time.Minute),
193+
UsagePercent7d: 45,
194+
UsagePercent7dValid: true,
195+
Reset7dAt: time.Now().Add(4 * 24 * time.Hour),
196+
}
197+
198+
snapshot := acc.GetSchedulerDebugSnapshot(4)
199+
200+
if snapshot.SchedulerScore != 100 {
201+
t.Fatalf("SchedulerScore = %v, want 100", snapshot.SchedulerScore)
202+
}
203+
if snapshot.Breakdown.UsageUrgencyBonus5h <= 20 {
204+
t.Fatalf("UsageUrgencyBonus5h = %v, want > 20", snapshot.Breakdown.UsageUrgencyBonus5h)
205+
}
206+
if snapshot.DispatchScore <= 170 {
207+
t.Fatalf("DispatchScore = %v, want plan bias plus urgency bonus", snapshot.DispatchScore)
208+
}
209+
if snapshot.HealthTier != string(HealthTierHealthy) {
210+
t.Fatalf("HealthTier = %q, want %q", snapshot.HealthTier, HealthTierHealthy)
211+
}
212+
}
213+
214+
func TestAccountPremium5hUrgencyBonusSkipsNearlyExhaustedWindow(t *testing.T) {
215+
acc := &Account{
216+
DBID: 1,
217+
AccessToken: "token",
218+
Status: StatusReady,
219+
PlanType: "plus",
220+
UsagePercent5h: 96,
221+
UsagePercent5hValid: true,
222+
Reset5hAt: time.Now().Add(30 * time.Minute),
223+
}
224+
225+
snapshot := acc.GetSchedulerDebugSnapshot(4)
226+
227+
if snapshot.Breakdown.UsageUrgencyBonus5h != 0 {
228+
t.Fatalf("UsageUrgencyBonus5h = %v, want 0", snapshot.Breakdown.UsageUrgencyBonus5h)
229+
}
230+
if snapshot.DispatchScore != 150 {
231+
t.Fatalf("DispatchScore = %v, want only plan bias", snapshot.DispatchScore)
232+
}
233+
}
234+
235+
func TestStoreNextPrefersPremium5hResetSoonWithinTier(t *testing.T) {
236+
now := time.Now()
237+
soon := &Account{
238+
DBID: 1,
239+
AccessToken: "token",
240+
Status: StatusReady,
241+
PlanType: "plus",
242+
UsagePercent5h: 25,
243+
UsagePercent5hValid: true,
244+
Reset5hAt: now.Add(30 * time.Minute),
245+
}
246+
later := &Account{
247+
DBID: 2,
248+
AccessToken: "token",
249+
Status: StatusReady,
250+
PlanType: "plus",
251+
UsagePercent5h: 25,
252+
UsagePercent5hValid: true,
253+
Reset5hAt: now.Add(5 * time.Hour),
254+
}
255+
recomputeTestAccount(soon, 2)
256+
recomputeTestAccount(later, 2)
257+
258+
store := &Store{
259+
accounts: []*Account{later, soon},
260+
}
261+
store.SetMaxConcurrency(2)
262+
263+
got := store.Next()
264+
if got == nil {
265+
t.Fatal("Next() returned nil")
266+
}
267+
defer store.Release(got)
268+
269+
if got.DBID != soon.DBID {
270+
t.Fatalf("Next() picked dbID=%d, want reset-soon account %d", got.DBID, soon.DBID)
271+
}
272+
}

frontend/src/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,8 @@
491491
"reasonTimeout": "Timeout",
492492
"reasonFailure": "Failure Run",
493493
"reasonLatency": "Latency",
494-
"reasonSuccess": "Success"
494+
"reasonSuccess": "Success",
495+
"reason5hUrgency": "5h Reset Soon"
495496
},
496497
"usage": {
497498
"title": "Usage Statistics",

frontend/src/locales/zh.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,8 @@
491491
"reasonTimeout": "超时",
492492
"reasonFailure": "失败串",
493493
"reasonLatency": "延迟",
494-
"reasonSuccess": "成功"
494+
"reasonSuccess": "成功",
495+
"reason5hUrgency": "5h 即将重置"
495496
},
496497
"usage": {
497498
"title": "使用统计",

frontend/src/pages/SchedulerBoard.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,9 @@ function buildScoreReasonTags(account: AccountRow, t: any) {
424424
if (breakdown.usage_penalty_7d > 0) {
425425
tags.push({ label: `7d -${Math.round(breakdown.usage_penalty_7d)}`, className: 'border-transparent bg-fuchsia-500/10 text-fuchsia-600 dark:bg-fuchsia-500/20 dark:text-fuchsia-300' })
426426
}
427+
if ((breakdown.usage_urgency_bonus_5h ?? 0) > 0) {
428+
tags.push({ label: `${t('scheduler.reason5hUrgency')} +${Math.round(breakdown.usage_urgency_bonus_5h ?? 0)}`, className: 'border-transparent bg-teal-500/10 text-teal-700 dark:bg-teal-500/20 dark:text-teal-300' })
429+
}
427430
if (breakdown.latency_penalty > 0) {
428431
tags.push({ label: `${t('scheduler.reasonLatency')} -${Math.round(breakdown.latency_penalty)}`, className: 'border-transparent bg-cyan-500/10 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-300' })
429432
}

frontend/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ export interface AccountRow {
5050
failure_penalty: number
5151
success_bonus: number
5252
usage_penalty_7d: number
53+
usage_urgency_bonus_5h?: number
5354
latency_penalty: number
55+
success_rate_penalty?: number
5456
}
5557
last_unauthorized_at?: ISODateString
5658
last_rate_limited_at?: ISODateString

0 commit comments

Comments
 (0)