scheduler_snapshot_service.go 52 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "log/slog"
  8. "sort"
  9. "strconv"
  10. "sync"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  14. )
  15. var (
  16. ErrSchedulerCacheNotReady = errors.New("scheduler cache not ready")
  17. ErrSchedulerFallbackLimited = errors.New("scheduler db fallback limited")
  18. ErrSchedulerGroupLifecycleLeaseBusy = errors.New("scheduler group lifecycle lease busy")
  19. ErrSchedulerBucketRebuildBusy = errors.New("scheduler bucket rebuild busy")
  20. )
  21. const (
  22. outboxEventTimeout = 2 * time.Minute
  23. schedulerOutboxCleanupBatch = 5000
  24. schedulerGroupLifecycleTimeout = 30 * time.Second
  25. schedulerGroupLifecycleLeaseTTL = 60 * time.Second
  26. schedulerGroupLifecycleReleaseTimeout = 2 * time.Second
  27. outboxRebuildRetryBaseDelay = 5 * time.Second
  28. outboxRebuildRetryMaxDelay = 5 * time.Minute
  29. outboxMaxIDErrorLogSampleInterval = time.Minute
  30. )
  31. // batchSeenKey tracks completed per-platform rebuilds and group lifecycle work
  32. // within one pollOutbox call.
  33. type batchSeenKey struct {
  34. groupID int64
  35. platform string
  36. lifecycle bool
  37. }
  38. type schedulerBucketWriteTask struct {
  39. bucket SchedulerBucket
  40. token SchedulerBucketWriteToken
  41. }
  42. type schedulerAccountQueryKey struct {
  43. groupID int64
  44. platform string
  45. }
  46. // 查询结果只在一次 rebuild batch 内,按原始 groupID+platform 复用成功的 single/forced 查询;
  47. // mixed 与历史模式保持独立。每个 task 都用 defer 消费 remaining,最后一个消费者会立即释放结果,
  48. // 避免把账号切片的生命周期扩大到整轮 full rebuild。
  49. type schedulerAccountQueryCache struct {
  50. remaining map[schedulerAccountQueryKey]int
  51. accounts map[schedulerAccountQueryKey][]Account
  52. snapshotAccountIDs map[schedulerAccountQueryKey][]int64
  53. }
  54. // schedulerSnapshotAccountIDWriter 是 SchedulerCache 的可选批次优化能力。
  55. // 首次完整发布成功后返回实际可编码账号 ID;同一查询结果的后续桶只需发布这些 ID,
  56. // 避免重复序列化并覆盖全局账号缓存。未实现该接口的缓存继续走原 SetSnapshot 路径。
  57. type schedulerSnapshotAccountIDWriter interface {
  58. SetSnapshotAndReturnAccountIDs(ctx context.Context, bucket SchedulerBucket, token SchedulerBucketWriteToken, accounts []Account) ([]int64, error)
  59. SetSnapshotByAccountIDs(ctx context.Context, bucket SchedulerBucket, token SchedulerBucketWriteToken, accountIDs []int64) error
  60. }
  61. func newSchedulerAccountQueryCache(taskSets ...[]schedulerBucketWriteTask) *schedulerAccountQueryCache {
  62. queries := &schedulerAccountQueryCache{
  63. remaining: make(map[schedulerAccountQueryKey]int),
  64. accounts: make(map[schedulerAccountQueryKey][]Account),
  65. snapshotAccountIDs: make(map[schedulerAccountQueryKey][]int64),
  66. }
  67. for _, tasks := range taskSets {
  68. for _, task := range tasks {
  69. if key, ok := schedulerAccountQueryKeyForBucket(task.bucket); ok {
  70. queries.remaining[key]++
  71. }
  72. }
  73. }
  74. return queries
  75. }
  76. func schedulerAccountQueryKeyForBucket(bucket SchedulerBucket) (schedulerAccountQueryKey, bool) {
  77. if bucket.Mode != SchedulerModeSingle && bucket.Mode != SchedulerModeForced {
  78. return schedulerAccountQueryKey{}, false
  79. }
  80. return schedulerAccountQueryKey{groupID: bucket.GroupID, platform: bucket.Platform}, true
  81. }
  82. func (c *schedulerAccountQueryCache) release(bucket SchedulerBucket) {
  83. if c == nil {
  84. return
  85. }
  86. key, ok := schedulerAccountQueryKeyForBucket(bucket)
  87. if !ok {
  88. return
  89. }
  90. remaining := c.remaining[key] - 1
  91. if remaining <= 0 {
  92. delete(c.remaining, key)
  93. delete(c.accounts, key)
  94. delete(c.snapshotAccountIDs, key)
  95. return
  96. }
  97. c.remaining[key] = remaining
  98. }
  99. type schedulerGroupLifecyclePlan struct {
  100. active bool
  101. tasks []schedulerBucketWriteTask
  102. }
  103. type schedulerActiveGroupIDLister interface {
  104. ListActiveIDs(ctx context.Context) ([]int64, error)
  105. }
  106. type SchedulerSnapshotService struct {
  107. cache SchedulerCache
  108. outboxRepo SchedulerOutboxRepository
  109. accountRepo AccountRepository
  110. groupRepo GroupRepository
  111. cfg *config.Config
  112. stopCh chan struct{}
  113. stopOnce sync.Once
  114. wg sync.WaitGroup
  115. fallbackLimit *fallbackLimiter
  116. lagMu sync.Mutex
  117. lagFailures int
  118. outboxRebuildLatched bool
  119. outboxRebuildRunning bool
  120. outboxRebuildFailures int
  121. outboxRebuildRetryAt time.Time
  122. outboxRebuildRetryReason string
  123. outboxLagWarningActive bool
  124. outboxMaxIDErrorLastLoggedAt time.Time
  125. fullRebuildRunMu sync.Mutex
  126. fullRebuildStateMu sync.Mutex
  127. fullRebuildRequested uint64
  128. fullRebuildCompleted uint64
  129. fullRebuildLastErr error
  130. }
  131. func NewSchedulerSnapshotService(
  132. cache SchedulerCache,
  133. outboxRepo SchedulerOutboxRepository,
  134. accountRepo AccountRepository,
  135. groupRepo GroupRepository,
  136. cfg *config.Config,
  137. ) *SchedulerSnapshotService {
  138. maxQPS := 0
  139. if cfg != nil {
  140. maxQPS = cfg.Gateway.Scheduling.DbFallbackMaxQPS
  141. }
  142. return &SchedulerSnapshotService{
  143. cache: cache,
  144. outboxRepo: outboxRepo,
  145. accountRepo: accountRepo,
  146. groupRepo: groupRepo,
  147. cfg: cfg,
  148. stopCh: make(chan struct{}),
  149. fallbackLimit: newFallbackLimiter(maxQPS),
  150. }
  151. }
  152. func (s *SchedulerSnapshotService) Start() {
  153. if s == nil || s.cache == nil {
  154. return
  155. }
  156. s.wg.Add(1)
  157. go func() {
  158. defer s.wg.Done()
  159. s.runInitialRebuild()
  160. }()
  161. interval := s.outboxPollInterval()
  162. if s.outboxRepo != nil && interval > 0 {
  163. s.wg.Add(1)
  164. go func() {
  165. defer s.wg.Done()
  166. s.runOutboxWorker(interval)
  167. }()
  168. }
  169. fullInterval := s.fullRebuildInterval()
  170. if fullInterval > 0 {
  171. s.wg.Add(1)
  172. go func() {
  173. defer s.wg.Done()
  174. s.runFullRebuildWorker(fullInterval)
  175. }()
  176. }
  177. }
  178. func (s *SchedulerSnapshotService) Stop() {
  179. if s == nil {
  180. return
  181. }
  182. s.stopOnce.Do(func() {
  183. close(s.stopCh)
  184. })
  185. s.wg.Wait()
  186. }
  187. func (s *SchedulerSnapshotService) ListSchedulableAccounts(ctx context.Context, groupID *int64, platform string, hasForcePlatform bool) ([]Account, bool, error) {
  188. useMixed := (platform == PlatformAnthropic || platform == PlatformGemini) && !hasForcePlatform
  189. mode := s.resolveMode(platform, hasForcePlatform)
  190. bucket := s.bucketFor(groupID, platform, mode)
  191. var writeToken SchedulerBucketWriteToken
  192. canPublish := false
  193. if err := ctx.Err(); err != nil {
  194. return nil, useMixed, err
  195. }
  196. if s.cache != nil {
  197. cached, hit, err := s.cache.GetSnapshot(ctx, bucket)
  198. if ctxErr := ctx.Err(); ctxErr != nil {
  199. return nil, useMixed, ctxErr
  200. }
  201. if err != nil {
  202. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] cache read failed: bucket=%s err=%v", bucket.String(), err)
  203. } else if hit {
  204. return derefAccounts(cached), useMixed, nil
  205. }
  206. token, err := s.cache.CaptureBucketWriteToken(ctx, bucket)
  207. if ctxErr := ctx.Err(); ctxErr != nil {
  208. return nil, useMixed, ctxErr
  209. }
  210. if err != nil {
  211. if errors.Is(err, ErrSchedulerBucketRetired) || errors.Is(err, ErrSchedulerBucketWriteFenced) {
  212. slog.Debug("[Scheduler] cache publish fenced", "bucket", bucket.String())
  213. } else {
  214. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] cache publish token failed: bucket=%s err=%v", bucket.String(), err)
  215. }
  216. } else {
  217. writeToken = token
  218. canPublish = true
  219. }
  220. }
  221. if err := s.guardFallback(ctx); err != nil {
  222. return nil, useMixed, err
  223. }
  224. fallbackCtx, cancel := s.withFallbackTimeout(ctx)
  225. defer cancel()
  226. accounts, err := s.loadAccountsFromDB(fallbackCtx, bucket, useMixed)
  227. if err != nil {
  228. return nil, useMixed, err
  229. }
  230. if ctxErr := ctx.Err(); ctxErr != nil {
  231. return nil, useMixed, ctxErr
  232. }
  233. if s.cache != nil && canPublish {
  234. if err := s.cache.SetSnapshot(fallbackCtx, bucket, writeToken, accounts); err != nil {
  235. if errors.Is(err, ErrSchedulerBucketRetired) || errors.Is(err, ErrSchedulerBucketWriteFenced) {
  236. slog.Debug("[Scheduler] cache publish fenced", "bucket", bucket.String())
  237. } else {
  238. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] cache write failed: bucket=%s err=%v", bucket.String(), err)
  239. }
  240. }
  241. }
  242. return accounts, useMixed, nil
  243. }
  244. func (s *SchedulerSnapshotService) GetAccount(ctx context.Context, accountID int64) (*Account, error) {
  245. if accountID <= 0 {
  246. return nil, nil
  247. }
  248. if err := ctx.Err(); err != nil {
  249. return nil, err
  250. }
  251. if s.cache != nil {
  252. account, err := s.cache.GetAccount(ctx, accountID)
  253. if ctxErr := ctx.Err(); ctxErr != nil {
  254. return nil, ctxErr
  255. }
  256. if err != nil {
  257. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] account cache read failed: id=%d err=%v", accountID, err)
  258. } else if account != nil {
  259. return account, nil
  260. }
  261. }
  262. if err := s.guardFallback(ctx); err != nil {
  263. return nil, err
  264. }
  265. fallbackCtx, cancel := s.withFallbackTimeout(ctx)
  266. defer cancel()
  267. return s.accountRepo.GetByID(fallbackCtx, accountID)
  268. }
  269. // GetGroupByID 获取分组信息(供调度器使用)
  270. func (s *SchedulerSnapshotService) GetGroupByID(ctx context.Context, groupID int64) (*Group, error) {
  271. if s.groupRepo == nil {
  272. return nil, nil
  273. }
  274. return s.groupRepo.GetByID(ctx, groupID)
  275. }
  276. // GetGroupByIDLite 获取分组配置但不加载账号计数聚合。
  277. // 利润门只需要平台、倍率、利润与高峰字段,GetByID 附带的那条账号计数聚合
  278. // 查询纯属浪费——composite / 模型路由 / fallback 每次装门都要付一次,WS 更是
  279. // 每个 turn 一次,且发生在「是否启用利润控制」判定之前。
  280. func (s *SchedulerSnapshotService) GetGroupByIDLite(ctx context.Context, groupID int64) (*Group, error) {
  281. if s.groupRepo == nil {
  282. return nil, nil
  283. }
  284. return s.groupRepo.GetByIDLite(ctx, groupID)
  285. }
  286. // UpdateAccountInCache 立即更新 Redis 中单个账号的数据(用于模型限流后立即生效)
  287. func (s *SchedulerSnapshotService) UpdateAccountInCache(ctx context.Context, account *Account) error {
  288. if s.cache == nil || account == nil {
  289. return nil
  290. }
  291. return s.cache.SetAccount(ctx, account)
  292. }
  293. func (s *SchedulerSnapshotService) runInitialRebuild() {
  294. if s.cache == nil {
  295. return
  296. }
  297. _ = s.coalesceFullRebuild(func() error {
  298. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
  299. defer cancel()
  300. if err := s.rebuildFullSnapshot(ctx, "startup"); err != nil {
  301. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] rebuild startup failed: %v", err)
  302. return err
  303. }
  304. return nil
  305. })
  306. }
  307. func (s *SchedulerSnapshotService) runOutboxWorker(interval time.Duration) {
  308. ticker := time.NewTicker(interval)
  309. defer ticker.Stop()
  310. s.pollOutbox()
  311. for {
  312. select {
  313. case <-ticker.C:
  314. s.pollOutbox()
  315. case <-s.stopCh:
  316. return
  317. }
  318. }
  319. }
  320. func (s *SchedulerSnapshotService) runFullRebuildWorker(interval time.Duration) {
  321. ticker := time.NewTicker(interval)
  322. defer ticker.Stop()
  323. for {
  324. select {
  325. case <-ticker.C:
  326. if err := s.triggerFullRebuild("interval"); err != nil {
  327. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] full rebuild failed: %v", err)
  328. }
  329. case <-s.stopCh:
  330. return
  331. }
  332. }
  333. }
  334. func (s *SchedulerSnapshotService) pollOutbox() {
  335. if s.outboxRepo == nil || s.cache == nil {
  336. return
  337. }
  338. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  339. defer cancel()
  340. watermark, err := s.cache.GetOutboxWatermark(ctx)
  341. if err != nil {
  342. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] outbox watermark read failed: %v", err)
  343. return
  344. }
  345. events, err := s.outboxRepo.ListAfterAndReleaseDedup(ctx, watermark, 200)
  346. if err != nil {
  347. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] outbox poll failed: %v", err)
  348. return
  349. }
  350. if len(events) == 0 {
  351. // The outbox query itself proves there is no event after the watermark.
  352. // Clear degraded/retry state without adding two more repository queries to
  353. // the healthy one-second poll path.
  354. s.clearOutboxDegradedEpisode()
  355. return
  356. }
  357. seen := make(map[batchSeenKey]struct{})
  358. for _, event := range events {
  359. eventCtx, cancel := context.WithTimeout(context.Background(), outboxEventTimeout)
  360. err := s.handleOutboxEvent(eventCtx, event, seen)
  361. cancel()
  362. if err != nil {
  363. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] outbox handle failed: id=%d type=%s err=%v", event.ID, event.EventType, err)
  364. return
  365. }
  366. }
  367. lastID := events[len(events)-1].ID
  368. var wmErr error
  369. for i := range 3 {
  370. wmCtx, wmCancel := context.WithTimeout(context.Background(), 5*time.Second)
  371. wmErr = s.cache.SetOutboxWatermark(wmCtx, lastID)
  372. wmCancel()
  373. if wmErr == nil {
  374. break
  375. }
  376. if i < 2 {
  377. time.Sleep(200 * time.Millisecond)
  378. }
  379. }
  380. if wmErr != nil {
  381. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] outbox watermark write failed: %v", wmErr)
  382. return
  383. }
  384. s.cleanupConsumedOutbox(lastID)
  385. // 只有 watermark 成功推进后,当前批次才算已消费。延迟必须按下一条待消费事件计算,
  386. // 否则本批次处理越慢,越容易误触发一次更慢的全量重建,形成正反馈。
  387. lagCtx, lagCancel := context.WithTimeout(context.Background(), 5*time.Second)
  388. s.checkOutboxLag(lagCtx, lastID)
  389. lagCancel()
  390. }
  391. func (s *SchedulerSnapshotService) cleanupConsumedOutbox(watermark int64) {
  392. if s == nil || s.outboxRepo == nil || watermark <= 0 {
  393. return
  394. }
  395. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  396. defer cancel()
  397. lease, acquired, err := s.outboxRepo.TryAcquireCleanupLock(ctx)
  398. if err != nil {
  399. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] outbox cleanup lock failed: %v", err)
  400. return
  401. }
  402. if !acquired {
  403. return
  404. }
  405. defer lease.Release()
  406. for {
  407. deleted, err := s.outboxRepo.DeleteConsumedUpTo(ctx, watermark, schedulerOutboxCleanupBatch)
  408. if err != nil {
  409. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] outbox cleanup failed: watermark=%d err=%v", watermark, err)
  410. return
  411. }
  412. if deleted == 0 || deleted < schedulerOutboxCleanupBatch {
  413. return
  414. }
  415. }
  416. }
  417. func (s *SchedulerSnapshotService) handleOutboxEvent(ctx context.Context, event SchedulerOutboxEvent, seen map[batchSeenKey]struct{}) error {
  418. switch event.EventType {
  419. case SchedulerOutboxEventAccountLastUsed:
  420. return s.handleLastUsedEvent(ctx, event.Payload)
  421. case SchedulerOutboxEventAccountBulkChanged:
  422. return s.handleBulkAccountEvent(ctx, event.Payload, seen)
  423. case SchedulerOutboxEventAccountGroupsChanged:
  424. return s.handleAccountEvent(ctx, event.AccountID, event.Payload, seen)
  425. case SchedulerOutboxEventAccountChanged:
  426. return s.handleAccountEvent(ctx, event.AccountID, event.Payload, seen)
  427. case SchedulerOutboxEventGroupChanged:
  428. return s.handleGroupEvent(ctx, event.GroupID, seen)
  429. case SchedulerOutboxEventFullRebuild:
  430. return s.triggerFullRebuild("outbox")
  431. default:
  432. return nil
  433. }
  434. }
  435. func (s *SchedulerSnapshotService) handleLastUsedEvent(ctx context.Context, payload map[string]any) error {
  436. if s.cache == nil || payload == nil {
  437. return nil
  438. }
  439. raw, ok := payload["last_used"].(map[string]any)
  440. if !ok || len(raw) == 0 {
  441. return nil
  442. }
  443. updates := make(map[int64]time.Time, len(raw))
  444. for key, value := range raw {
  445. id, err := strconv.ParseInt(key, 10, 64)
  446. if err != nil || id <= 0 {
  447. continue
  448. }
  449. sec, ok := toInt64(value)
  450. if !ok || sec <= 0 {
  451. continue
  452. }
  453. updates[id] = time.Unix(sec, 0)
  454. }
  455. if len(updates) == 0 {
  456. return nil
  457. }
  458. return s.cache.UpdateLastUsed(ctx, updates)
  459. }
  460. func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, payload map[string]any, seen map[batchSeenKey]struct{}) error {
  461. if payload == nil {
  462. return nil
  463. }
  464. if s.accountRepo == nil {
  465. return nil
  466. }
  467. rawIDs := parseInt64Slice(payload["account_ids"])
  468. if len(rawIDs) == 0 {
  469. return nil
  470. }
  471. ids := make([]int64, 0, len(rawIDs))
  472. seenIDs := make(map[int64]struct{}, len(rawIDs))
  473. for _, id := range rawIDs {
  474. if id <= 0 {
  475. continue
  476. }
  477. if _, exists := seenIDs[id]; exists {
  478. continue
  479. }
  480. seenIDs[id] = struct{}{}
  481. ids = append(ids, id)
  482. }
  483. if len(ids) == 0 {
  484. return nil
  485. }
  486. preloadGroupIDs := parseInt64Slice(payload["group_ids"])
  487. accounts, err := s.accountRepo.GetByIDs(ctx, ids)
  488. if err != nil {
  489. return err
  490. }
  491. found := make(map[int64]struct{}, len(accounts))
  492. rebuildGroupSet := make(map[int64]struct{}, len(preloadGroupIDs))
  493. for _, gid := range preloadGroupIDs {
  494. if gid > 0 {
  495. rebuildGroupSet[gid] = struct{}{}
  496. }
  497. }
  498. for _, account := range accounts {
  499. if account == nil || account.ID <= 0 {
  500. continue
  501. }
  502. found[account.ID] = struct{}{}
  503. if s.cache != nil {
  504. if err := s.cache.SetAccount(ctx, account); err != nil {
  505. return err
  506. }
  507. }
  508. for _, gid := range account.GroupIDs {
  509. if gid > 0 {
  510. rebuildGroupSet[gid] = struct{}{}
  511. }
  512. }
  513. }
  514. allAccountsFound := true
  515. for _, id := range ids {
  516. if _, ok := found[id]; ok {
  517. continue
  518. }
  519. allAccountsFound = false
  520. if s.cache != nil {
  521. if err := s.cache.DeleteAccount(ctx, id); err != nil {
  522. return err
  523. }
  524. }
  525. }
  526. rebuildGroupIDs := make([]int64, 0, len(rebuildGroupSet))
  527. for gid := range rebuildGroupSet {
  528. rebuildGroupIDs = append(rebuildGroupIDs, gid)
  529. }
  530. // 缺失账户无法确定原平台,保留全平台重建以避免遗留旧快照。
  531. if !allAccountsFound {
  532. return s.rebuildByGroupIDs(ctx, rebuildGroupIDs, "account_bulk_change", seen)
  533. }
  534. platformGroupSets := make(map[string]map[int64]struct{}, len(accounts))
  535. addPlatformGroups := func(platform string, groupIDs []int64) {
  536. groupSet := platformGroupSets[platform]
  537. if groupSet == nil {
  538. groupSet = make(map[int64]struct{}, len(groupIDs))
  539. platformGroupSets[platform] = groupSet
  540. }
  541. for _, groupID := range groupIDs {
  542. groupSet[groupID] = struct{}{}
  543. }
  544. }
  545. for _, account := range accounts {
  546. if account == nil || account.ID <= 0 {
  547. continue
  548. }
  549. accountGroupIDs := s.normalizeGroupIDs(account.GroupIDs)
  550. switch account.Platform {
  551. case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
  552. addPlatformGroups(account.Platform, accountGroupIDs)
  553. case PlatformAntigravity:
  554. // 批量更新可能刚关闭 mixed_scheduling,仍需清理两个兼容平台的旧快照。
  555. addPlatformGroups(PlatformAntigravity, accountGroupIDs)
  556. addPlatformGroups(PlatformAnthropic, accountGroupIDs)
  557. addPlatformGroups(PlatformGemini, accountGroupIDs)
  558. default:
  559. return s.rebuildByGroupIDs(ctx, rebuildGroupIDs, "account_bulk_change", seen)
  560. }
  561. }
  562. // payload 携带更新前的组;只扩散到本事件实际涉及的平台,避免平台间交叉重建。
  563. if len(preloadGroupIDs) > 0 {
  564. preloadGroupIDs = s.normalizeGroupIDs(preloadGroupIDs)
  565. for platform := range platformGroupSets {
  566. addPlatformGroups(platform, preloadGroupIDs)
  567. }
  568. }
  569. bucketCapacity := 0
  570. for _, groupSet := range platformGroupSets {
  571. bucketCapacity += len(groupSet) * 3
  572. }
  573. buckets := make([]SchedulerBucket, 0, bucketCapacity)
  574. for _, platform := range schedulerSnapshotPlatforms() {
  575. groupSet, ok := platformGroupSets[platform]
  576. if !ok {
  577. continue
  578. }
  579. platformGroupIDs := make([]int64, 0, len(groupSet))
  580. for groupID := range groupSet {
  581. platformGroupIDs = append(platformGroupIDs, groupID)
  582. }
  583. sort.Slice(platformGroupIDs, func(i, j int) bool { return platformGroupIDs[i] < platformGroupIDs[j] })
  584. buckets = append(buckets, s.bucketsForPlatform(platform, platformGroupIDs, seen)...)
  585. }
  586. return s.rebuildBuckets(ctx, buckets, "account_bulk_change")
  587. }
  588. func (s *SchedulerSnapshotService) handleAccountEvent(ctx context.Context, accountID *int64, payload map[string]any, seen map[batchSeenKey]struct{}) error {
  589. if accountID == nil || *accountID <= 0 {
  590. return nil
  591. }
  592. if s.accountRepo == nil {
  593. return nil
  594. }
  595. var groupIDs []int64
  596. if payload != nil {
  597. groupIDs = parseInt64Slice(payload["group_ids"])
  598. }
  599. account, err := s.accountRepo.GetByID(ctx, *accountID)
  600. if err != nil {
  601. if errors.Is(err, ErrAccountNotFound) {
  602. if s.cache != nil {
  603. if err := s.cache.DeleteAccount(ctx, *accountID); err != nil {
  604. return err
  605. }
  606. }
  607. return s.rebuildByGroupIDs(ctx, groupIDs, "account_miss", seen)
  608. }
  609. return err
  610. }
  611. if s.cache != nil {
  612. if err := s.cache.SetAccount(ctx, account); err != nil {
  613. return err
  614. }
  615. }
  616. if len(groupIDs) == 0 {
  617. groupIDs = account.GroupIDs
  618. }
  619. return s.rebuildByAccount(ctx, account, groupIDs, "account_change", seen)
  620. }
  621. func (s *SchedulerSnapshotService) handleGroupEvent(ctx context.Context, groupID *int64, seen map[batchSeenKey]struct{}) error {
  622. if groupID == nil || *groupID <= 0 || s.isRunModeSimple() {
  623. return nil
  624. }
  625. if seen != nil {
  626. if _, ok := seen[batchSeenKey{groupID: *groupID, lifecycle: true}]; ok {
  627. return nil
  628. }
  629. }
  630. return s.reconcileGroupLifecycle(ctx, *groupID, seen)
  631. }
  632. func (s *SchedulerSnapshotService) reconcileGroupLifecycle(ctx context.Context, groupID int64, seen map[batchSeenKey]struct{}) error {
  633. plan, err := s.prepareGroupLifecycle(ctx, groupID, nil)
  634. if err != nil {
  635. return err
  636. }
  637. if plan.active {
  638. queries := newSchedulerAccountQueryCache(plan.tasks)
  639. for _, task := range plan.tasks {
  640. if err := s.rebuildBucketWithTokenPolicyAndQueryCache(ctx, task, "group_change", true, queries); err != nil {
  641. return err
  642. }
  643. }
  644. }
  645. markGroupLifecycleSeen(seen, groupID)
  646. return nil
  647. }
  648. // 生命周期决策必须在所有者安全的租约内读取 fresh 且完整的分组权威状态。
  649. // active 仅 Reopen canonical bucket;missing/inactive 同时 Retire canonical 与已登记历史 bucket;
  650. // group event 路径只有在权威决策和后续重建全部成功后才会标记 seen。
  651. func (s *SchedulerSnapshotService) prepareGroupLifecycle(ctx context.Context, groupID int64, knownHistorical []SchedulerBucket) (plan schedulerGroupLifecyclePlan, retErr error) {
  652. if groupID <= 0 || s.isRunModeSimple() {
  653. return schedulerGroupLifecyclePlan{}, nil
  654. }
  655. if s.cache == nil || s.groupRepo == nil {
  656. return schedulerGroupLifecyclePlan{}, ErrSchedulerCacheNotReady
  657. }
  658. lifecycleCtx, cancel := context.WithTimeout(ctx, schedulerGroupLifecycleTimeout)
  659. defer cancel()
  660. lease, acquired, err := s.cache.TryAcquireGroupLifecycleLease(lifecycleCtx, groupID, schedulerGroupLifecycleLeaseTTL)
  661. if err != nil {
  662. return schedulerGroupLifecyclePlan{}, err
  663. }
  664. if !acquired {
  665. return schedulerGroupLifecyclePlan{}, fmt.Errorf("%w: group=%d", ErrSchedulerGroupLifecycleLeaseBusy, groupID)
  666. }
  667. leaseHeld := true
  668. defer func() {
  669. if leaseHeld {
  670. retErr = errors.Join(retErr, s.releaseGroupLifecycleLease(lease))
  671. }
  672. }()
  673. group, err := s.groupRepo.GetByIDLite(lifecycleCtx, groupID)
  674. missing := errors.Is(err, ErrGroupNotFound)
  675. if err != nil && !missing {
  676. return schedulerGroupLifecyclePlan{}, err
  677. }
  678. if err == nil && (group == nil || group.ID != groupID || !group.Hydrated) {
  679. return schedulerGroupLifecyclePlan{}, fmt.Errorf("untrusted scheduler group lifecycle state: group=%d", groupID)
  680. }
  681. plan = schedulerGroupLifecyclePlan{active: !missing && group.IsActive()}
  682. if plan.active {
  683. buckets := schedulerBucketsForGroup(groupID)
  684. plan.tasks = make([]schedulerBucketWriteTask, 0, len(buckets))
  685. for _, bucket := range buckets {
  686. token, err := s.cache.ReopenBucket(lifecycleCtx, bucket)
  687. if err != nil {
  688. return schedulerGroupLifecyclePlan{}, err
  689. }
  690. plan.tasks = append(plan.tasks, schedulerBucketWriteTask{bucket: bucket, token: token})
  691. }
  692. } else {
  693. registered := knownHistorical
  694. if registered == nil {
  695. registered, err = s.cache.ListBuckets(lifecycleCtx)
  696. if err != nil {
  697. return schedulerGroupLifecyclePlan{}, err
  698. }
  699. }
  700. buckets := schedulerBucketsForGroup(groupID)
  701. for _, bucket := range registered {
  702. if bucket.GroupID == groupID {
  703. buckets = append(buckets, bucket)
  704. }
  705. }
  706. for _, bucket := range dedupeBuckets(buckets) {
  707. if err := s.cache.RetireBucket(lifecycleCtx, bucket); err != nil {
  708. return schedulerGroupLifecyclePlan{}, err
  709. }
  710. }
  711. }
  712. releaseErr := s.releaseGroupLifecycleLease(lease)
  713. leaseHeld = false
  714. if releaseErr != nil {
  715. return schedulerGroupLifecyclePlan{}, releaseErr
  716. }
  717. return plan, nil
  718. }
  719. func (s *SchedulerSnapshotService) releaseGroupLifecycleLease(lease SchedulerGroupLifecycleLease) error {
  720. // 请求取消后仍需尝试释放自己的租约,因此使用独立且有界的后台上下文。
  721. releaseCtx, cancel := context.WithTimeout(context.Background(), schedulerGroupLifecycleReleaseTimeout)
  722. defer cancel()
  723. return s.cache.ReleaseGroupLifecycleLease(releaseCtx, lease)
  724. }
  725. func markGroupLifecycleSeen(seen map[batchSeenKey]struct{}, groupID int64) {
  726. if seen == nil {
  727. return
  728. }
  729. seen[batchSeenKey{groupID: groupID, lifecycle: true}] = struct{}{}
  730. for _, platform := range schedulerSnapshotPlatforms() {
  731. seen[batchSeenKey{groupID: groupID, platform: platform}] = struct{}{}
  732. }
  733. }
  734. func (s *SchedulerSnapshotService) rebuildByAccount(ctx context.Context, account *Account, groupIDs []int64, reason string, seen map[batchSeenKey]struct{}) error {
  735. if account == nil {
  736. return nil
  737. }
  738. groupIDs = s.normalizeGroupIDs(groupIDs)
  739. if len(groupIDs) == 0 {
  740. return nil
  741. }
  742. buckets := s.bucketsForPlatform(account.Platform, groupIDs, seen)
  743. if account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled() {
  744. buckets = append(buckets, s.bucketsForPlatform(PlatformAnthropic, groupIDs, seen)...)
  745. buckets = append(buckets, s.bucketsForPlatform(PlatformGemini, groupIDs, seen)...)
  746. }
  747. return s.rebuildBuckets(ctx, buckets, reason)
  748. }
  749. func schedulerSnapshotPlatforms() [9]string {
  750. return [9]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax}
  751. }
  752. // 生命周期辅助函数有意排除 group0;full rebuild 构造 group0 canonical 集时必须显式调用 canonical helper。
  753. func schedulerBucketsForGroup(groupID int64) []SchedulerBucket {
  754. if groupID <= 0 {
  755. return nil
  756. }
  757. return schedulerCanonicalBuckets(groupID)
  758. }
  759. func schedulerCanonicalBucketCount() int {
  760. count := 0
  761. for _, platform := range schedulerSnapshotPlatforms() {
  762. count += 2
  763. if platform == PlatformAnthropic || platform == PlatformGemini {
  764. count++
  765. }
  766. }
  767. return count
  768. }
  769. func schedulerCanonicalBuckets(groupID int64) []SchedulerBucket {
  770. platforms := schedulerSnapshotPlatforms()
  771. buckets := make([]SchedulerBucket, 0, len(platforms)*2+2)
  772. for _, platform := range platforms {
  773. buckets = append(buckets,
  774. SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeSingle},
  775. SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeForced},
  776. )
  777. if platform == PlatformAnthropic || platform == PlatformGemini {
  778. buckets = append(buckets, SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeMixed})
  779. }
  780. }
  781. return buckets
  782. }
  783. func (s *SchedulerSnapshotService) rebuildByGroupIDs(ctx context.Context, groupIDs []int64, reason string, seen map[batchSeenKey]struct{}) error {
  784. groupIDs = s.normalizeGroupIDs(groupIDs)
  785. if len(groupIDs) == 0 {
  786. return nil
  787. }
  788. buckets := make([]SchedulerBucket, 0, len(groupIDs)*schedulerCanonicalBucketCount())
  789. for _, platform := range schedulerSnapshotPlatforms() {
  790. buckets = append(buckets, s.bucketsForPlatform(platform, groupIDs, seen)...)
  791. }
  792. return s.rebuildBuckets(ctx, buckets, reason)
  793. }
  794. func (s *SchedulerSnapshotService) bucketsForPlatform(platform string, groupIDs []int64, seen map[batchSeenKey]struct{}) []SchedulerBucket {
  795. if platform == "" {
  796. return nil
  797. }
  798. buckets := make([]SchedulerBucket, 0, len(groupIDs)*3)
  799. for _, gid := range groupIDs {
  800. // Within a single poll batch, skip (groupID, platform) pairs that were
  801. // already rebuilt. The first rebuild loads fresh DB data for all accounts
  802. // in the group, so subsequent rebuilds for the same group+platform within
  803. // the same batch are redundant.
  804. if seen != nil {
  805. key := batchSeenKey{groupID: gid, platform: platform}
  806. if _, exists := seen[key]; exists {
  807. continue
  808. }
  809. seen[key] = struct{}{}
  810. }
  811. buckets = append(buckets, SchedulerBucket{GroupID: gid, Platform: platform, Mode: SchedulerModeSingle})
  812. buckets = append(buckets, SchedulerBucket{GroupID: gid, Platform: platform, Mode: SchedulerModeForced})
  813. if platform == PlatformAnthropic || platform == PlatformGemini {
  814. buckets = append(buckets, SchedulerBucket{GroupID: gid, Platform: platform, Mode: SchedulerModeMixed})
  815. }
  816. }
  817. return buckets
  818. }
  819. func (s *SchedulerSnapshotService) rebuildBuckets(ctx context.Context, buckets []SchedulerBucket, reason string) error {
  820. tasks, firstErr := s.prepareBucketWriteTasks(ctx, buckets)
  821. queries := newSchedulerAccountQueryCache(tasks)
  822. if err := s.rebuildPreparedBucketTasks(ctx, tasks, reason, false, queries); err != nil && firstErr == nil {
  823. firstErr = err
  824. }
  825. return firstErr
  826. }
  827. func (s *SchedulerSnapshotService) prepareBucketWriteTasks(ctx context.Context, buckets []SchedulerBucket) ([]schedulerBucketWriteTask, error) {
  828. if s.cache == nil {
  829. return nil, ErrSchedulerCacheNotReady
  830. }
  831. tasks := make([]schedulerBucketWriteTask, 0, len(buckets))
  832. var firstErr error
  833. for _, bucket := range buckets {
  834. token, err := s.cache.CaptureBucketWriteToken(ctx, bucket)
  835. if err != nil {
  836. if errors.Is(err, ErrSchedulerBucketRetired) || errors.Is(err, ErrSchedulerBucketWriteFenced) {
  837. continue
  838. }
  839. if firstErr == nil {
  840. firstErr = err
  841. }
  842. continue
  843. }
  844. tasks = append(tasks, schedulerBucketWriteTask{bucket: bucket, token: token})
  845. }
  846. return tasks, firstErr
  847. }
  848. func (s *SchedulerSnapshotService) rebuildPreparedBucketTasks(
  849. ctx context.Context,
  850. tasks []schedulerBucketWriteTask,
  851. reason string,
  852. strict bool,
  853. queries *schedulerAccountQueryCache,
  854. ) error {
  855. var firstErr error
  856. for _, task := range tasks {
  857. if err := s.rebuildBucketWithTokenPolicyAndQueryCache(ctx, task, reason, strict, queries); err != nil && firstErr == nil {
  858. firstErr = err
  859. }
  860. }
  861. return firstErr
  862. }
  863. func (s *SchedulerSnapshotService) rebuildBucketWithTokenPolicyAndQueryCache(
  864. ctx context.Context,
  865. task schedulerBucketWriteTask,
  866. reason string,
  867. strict bool,
  868. queries *schedulerAccountQueryCache,
  869. ) error {
  870. if queries != nil {
  871. defer queries.release(task.bucket)
  872. }
  873. if s.cache == nil {
  874. return ErrSchedulerCacheNotReady
  875. }
  876. bucket := task.bucket
  877. ok, err := s.cache.TryLockBucket(ctx, bucket, 30*time.Second)
  878. if err != nil {
  879. return err
  880. }
  881. if !ok {
  882. if strict {
  883. return fmt.Errorf("%w: bucket=%s", ErrSchedulerBucketRebuildBusy, bucket.String())
  884. }
  885. return nil
  886. }
  887. defer func() {
  888. _ = s.cache.UnlockBucket(ctx, bucket)
  889. }()
  890. rebuildCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
  891. defer cancel()
  892. accounts, err := s.loadAccountsForRebuild(rebuildCtx, bucket, queries)
  893. if err != nil {
  894. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] rebuild failed: bucket=%s reason=%s err=%v", bucket.String(), reason, err)
  895. return err
  896. }
  897. if err := s.setRebuildSnapshot(rebuildCtx, task, accounts, queries); err != nil {
  898. if errors.Is(err, ErrSchedulerBucketRetired) || errors.Is(err, ErrSchedulerBucketWriteFenced) {
  899. slog.Debug("[Scheduler] rebuild fenced", "bucket", bucket.String(), "reason", reason)
  900. if strict {
  901. return err
  902. }
  903. return nil
  904. }
  905. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] rebuild cache failed: bucket=%s reason=%s err=%v", bucket.String(), reason, err)
  906. return err
  907. }
  908. slog.Debug("[Scheduler] rebuild ok", "bucket", bucket.String(), "reason", reason, "size", len(accounts))
  909. return nil
  910. }
  911. func (s *SchedulerSnapshotService) setRebuildSnapshot(
  912. ctx context.Context,
  913. task schedulerBucketWriteTask,
  914. accounts []Account,
  915. queries *schedulerAccountQueryCache,
  916. ) error {
  917. writer, ok := s.cache.(schedulerSnapshotAccountIDWriter)
  918. key, reusable := schedulerAccountQueryKeyForBucket(task.bucket)
  919. if !ok || queries == nil || !reusable {
  920. return s.cache.SetSnapshot(ctx, task.bucket, task.token, accounts)
  921. }
  922. if accountIDs, exists := queries.snapshotAccountIDs[key]; exists {
  923. return writer.SetSnapshotByAccountIDs(ctx, task.bucket, task.token, accountIDs)
  924. }
  925. if queries.remaining[key] <= 1 {
  926. return s.cache.SetSnapshot(ctx, task.bucket, task.token, accounts)
  927. }
  928. accountIDs, err := writer.SetSnapshotAndReturnAccountIDs(ctx, task.bucket, task.token, accounts)
  929. if err != nil {
  930. return err
  931. }
  932. if queries.remaining[key] > 1 {
  933. // 必须保存实际成功编码并写入的有序 ID,不能从原账号切片重新推导;
  934. // 否则不可编码账号会只出现在后续桶中,破坏两个快照的成员一致性。
  935. // 返回切片由当前批次独占,直接接管可避免 10k 账号场景再次复制。
  936. queries.snapshotAccountIDs[key] = accountIDs
  937. }
  938. return nil
  939. }
  940. func (s *SchedulerSnapshotService) triggerFullRebuild(reason string) error {
  941. if s.cache == nil {
  942. return ErrSchedulerCacheNotReady
  943. }
  944. return s.coalesceFullRebuild(func() error {
  945. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
  946. defer cancel()
  947. return s.rebuildFullSnapshot(ctx, reason)
  948. })
  949. }
  950. func (s *SchedulerSnapshotService) rebuildFullSnapshot(ctx context.Context, reason string) error {
  951. if s.cache == nil {
  952. return ErrSchedulerCacheNotReady
  953. }
  954. // 当前模式所需的全局读取必须先成功:桶注册表始终必需,standard 还需活跃分组 ID;
  955. // 失败时不执行 Capture/Retire/Reopen 或 DB 查询。
  956. // simple 模式不获取分组生命周期权威;standard 的 stale candidate 仍须在租约内 fresh 确认后才能退休。
  957. registered, err := s.cache.ListBuckets(ctx)
  958. if err != nil {
  959. return err
  960. }
  961. registered = dedupeBuckets(registered)
  962. if s.isRunModeSimple() {
  963. canonical := schedulerCanonicalBuckets(0)
  964. captured, err := s.captureFullRebuildCanonicalTasks(ctx, canonical)
  965. if err != nil {
  966. return err
  967. }
  968. ordinary := appendBucketsExcept(nil, registered, canonical)
  969. return s.prepareAndRebuildFullSnapshot(ctx, captured, nil, ordinary, reason)
  970. }
  971. activeGroupIDs, err := s.listActiveSchedulerGroupIDs(ctx)
  972. if err != nil {
  973. return err
  974. }
  975. activeGroups := make(map[int64]struct{}, len(activeGroupIDs))
  976. for _, groupID := range activeGroupIDs {
  977. activeGroups[groupID] = struct{}{}
  978. }
  979. registeredByGroup := make(map[int64][]SchedulerBucket)
  980. for _, bucket := range registered {
  981. registeredByGroup[bucket.GroupID] = append(registeredByGroup[bucket.GroupID], bucket)
  982. }
  983. groupZeroCanonical := schedulerCanonicalBuckets(0)
  984. capturedTasks, err := s.captureFullRebuildCanonicalTasks(ctx, groupZeroCanonical)
  985. if err != nil {
  986. return err
  987. }
  988. ordinaryBuckets := appendBucketsExcept(nil, registeredByGroup[0], groupZeroCanonical)
  989. for groupID, buckets := range registeredByGroup {
  990. if groupID < 0 {
  991. ordinaryBuckets = append(ordinaryBuckets, buckets...)
  992. }
  993. }
  994. reopenedTasks := make([]schedulerBucketWriteTask, 0)
  995. for _, groupID := range activeGroupIDs {
  996. canonical := schedulerBucketsForGroup(groupID)
  997. canonicalTasks, captureErr := s.captureFullRebuildCanonicalTasks(ctx, canonical)
  998. if captureErr == nil {
  999. capturedTasks = append(capturedTasks, canonicalTasks...)
  1000. ordinaryBuckets = appendBucketsExcept(ordinaryBuckets, registeredByGroup[groupID], canonical)
  1001. continue
  1002. }
  1003. if !errors.Is(captureErr, ErrSchedulerBucketRetired) && !errors.Is(captureErr, ErrSchedulerBucketWriteFenced) {
  1004. return captureErr
  1005. }
  1006. // A prior full_rebuild event can observe the active state committed for a
  1007. // later group_changed event. Recover here under fresh authority so the
  1008. // earlier event cannot block the outbox watermark before that event runs.
  1009. knownHistorical := registeredByGroup[groupID]
  1010. if knownHistorical == nil {
  1011. knownHistorical = []SchedulerBucket{}
  1012. }
  1013. plan, err := s.prepareGroupLifecycle(ctx, groupID, knownHistorical)
  1014. if err != nil {
  1015. return err
  1016. }
  1017. if plan.active {
  1018. reopenedTasks = append(reopenedTasks, plan.tasks...)
  1019. ordinaryBuckets = appendBucketsExcept(ordinaryBuckets, registeredByGroup[groupID], canonical)
  1020. }
  1021. }
  1022. staleGroupIDs := make([]int64, 0)
  1023. for groupID := range registeredByGroup {
  1024. if groupID <= 0 {
  1025. continue
  1026. }
  1027. if _, active := activeGroups[groupID]; !active {
  1028. staleGroupIDs = append(staleGroupIDs, groupID)
  1029. }
  1030. }
  1031. sort.Slice(staleGroupIDs, func(i, j int) bool { return staleGroupIDs[i] < staleGroupIDs[j] })
  1032. for _, groupID := range staleGroupIDs {
  1033. plan, err := s.prepareGroupLifecycle(ctx, groupID, registeredByGroup[groupID])
  1034. if err != nil {
  1035. return err
  1036. }
  1037. if plan.active {
  1038. reopenedTasks = append(reopenedTasks, plan.tasks...)
  1039. ordinaryBuckets = appendBucketsExcept(ordinaryBuckets, registeredByGroup[groupID], schedulerBucketsForGroup(groupID))
  1040. }
  1041. }
  1042. return s.prepareAndRebuildFullSnapshot(ctx, capturedTasks, reopenedTasks, ordinaryBuckets, reason)
  1043. }
  1044. func (s *SchedulerSnapshotService) listActiveSchedulerGroupIDs(ctx context.Context) ([]int64, error) {
  1045. if s.groupRepo == nil {
  1046. return nil, ErrSchedulerCacheNotReady
  1047. }
  1048. // 轻量接口一旦实现,其错误直接失败;只有仓储不支持该接口时才回退完整 ListActive。
  1049. var groupIDs []int64
  1050. if lister, ok := s.groupRepo.(schedulerActiveGroupIDLister); ok {
  1051. ids, err := lister.ListActiveIDs(ctx)
  1052. if err != nil {
  1053. return nil, err
  1054. }
  1055. groupIDs = ids
  1056. } else {
  1057. groups, err := s.groupRepo.ListActive(ctx)
  1058. if err != nil {
  1059. return nil, err
  1060. }
  1061. groupIDs = make([]int64, 0, len(groups))
  1062. for _, group := range groups {
  1063. groupIDs = append(groupIDs, group.ID)
  1064. }
  1065. }
  1066. seen := make(map[int64]struct{}, len(groupIDs))
  1067. normalized := make([]int64, 0, len(groupIDs))
  1068. for _, groupID := range groupIDs {
  1069. if groupID <= 0 {
  1070. continue
  1071. }
  1072. if _, ok := seen[groupID]; ok {
  1073. continue
  1074. }
  1075. seen[groupID] = struct{}{}
  1076. normalized = append(normalized, groupID)
  1077. }
  1078. sort.Slice(normalized, func(i, j int) bool { return normalized[i] < normalized[j] })
  1079. return normalized, nil
  1080. }
  1081. func (s *SchedulerSnapshotService) prepareAndRebuildFullSnapshot(
  1082. ctx context.Context,
  1083. captured []schedulerBucketWriteTask,
  1084. reopened []schedulerBucketWriteTask,
  1085. ordinaryBuckets []SchedulerBucket,
  1086. reason string,
  1087. ) error {
  1088. // 首个 DB 查询前必须完成全部普通 bucket 的 token 预备;任何预备错误都不会留下部分发布。
  1089. // fresh Reopen task 保持严格锁与 fencing 语义,普通 captured task 继续沿用 lock busy/fence 跳过语义。
  1090. preparedBuckets := make(map[SchedulerBucket]struct{}, len(captured)+len(reopened))
  1091. for _, task := range captured {
  1092. preparedBuckets[task.bucket] = struct{}{}
  1093. }
  1094. for _, task := range reopened {
  1095. preparedBuckets[task.bucket] = struct{}{}
  1096. }
  1097. ordinaryBuckets = dedupeBuckets(ordinaryBuckets)
  1098. toCapture := make([]SchedulerBucket, 0, len(ordinaryBuckets))
  1099. for _, bucket := range ordinaryBuckets {
  1100. if _, ok := preparedBuckets[bucket]; !ok {
  1101. toCapture = append(toCapture, bucket)
  1102. }
  1103. }
  1104. ordinary, firstErr := s.prepareBucketWriteTasks(ctx, toCapture)
  1105. if firstErr != nil {
  1106. return firstErr
  1107. }
  1108. captured = append(captured, ordinary...)
  1109. queries := newSchedulerAccountQueryCache(reopened, captured)
  1110. if err := s.rebuildPreparedBucketTasks(ctx, reopened, reason, true, queries); err != nil {
  1111. firstErr = err
  1112. }
  1113. if err := s.rebuildPreparedBucketTasks(ctx, captured, reason, false, queries); err != nil && firstErr == nil {
  1114. firstErr = err
  1115. }
  1116. return firstErr
  1117. }
  1118. func (s *SchedulerSnapshotService) captureFullRebuildCanonicalTasks(ctx context.Context, buckets []SchedulerBucket) ([]schedulerBucketWriteTask, error) {
  1119. if s.cache == nil {
  1120. return nil, ErrSchedulerCacheNotReady
  1121. }
  1122. tasks := make([]schedulerBucketWriteTask, 0, len(buckets))
  1123. for _, bucket := range buckets {
  1124. token, err := s.cache.CaptureBucketWriteToken(ctx, bucket)
  1125. if err != nil {
  1126. return nil, err
  1127. }
  1128. tasks = append(tasks, schedulerBucketWriteTask{bucket: bucket, token: token})
  1129. }
  1130. return tasks, nil
  1131. }
  1132. func appendBucketsExcept(dst, buckets, excluded []SchedulerBucket) []SchedulerBucket {
  1133. excludedKeys := make(map[SchedulerBucket]struct{}, len(excluded))
  1134. for _, bucket := range excluded {
  1135. excludedKeys[bucket] = struct{}{}
  1136. }
  1137. for _, bucket := range buckets {
  1138. if _, ok := excludedKeys[bucket]; !ok {
  1139. dst = append(dst, bucket)
  1140. }
  1141. }
  1142. return dst
  1143. }
  1144. func (s *SchedulerSnapshotService) coalesceFullRebuild(run func() error) error {
  1145. s.fullRebuildStateMu.Lock()
  1146. s.fullRebuildRequested++
  1147. requestID := s.fullRebuildRequested
  1148. s.fullRebuildStateMu.Unlock()
  1149. s.fullRebuildRunMu.Lock()
  1150. defer s.fullRebuildRunMu.Unlock()
  1151. s.fullRebuildStateMu.Lock()
  1152. if s.fullRebuildCompleted >= requestID {
  1153. err := s.fullRebuildLastErr
  1154. s.fullRebuildStateMu.Unlock()
  1155. return err
  1156. }
  1157. // 当前轮重建可能早于新 outbox 事件对应事务的提交,不能让后到请求直接复用当前轮。
  1158. // 每轮开始前记录可覆盖的请求代次,执行期间登记的请求统一合并到下一轮。
  1159. coveredThrough := s.fullRebuildRequested
  1160. s.fullRebuildStateMu.Unlock()
  1161. err := run()
  1162. s.fullRebuildStateMu.Lock()
  1163. s.fullRebuildCompleted = coveredThrough
  1164. s.fullRebuildLastErr = err
  1165. s.fullRebuildStateMu.Unlock()
  1166. return err
  1167. }
  1168. func (s *SchedulerSnapshotService) checkOutboxLag(ctx context.Context, watermark int64) {
  1169. if s.cfg == nil || s.outboxRepo == nil {
  1170. return
  1171. }
  1172. now := time.Now()
  1173. oldestCreatedAt, ok, err := s.outboxRepo.FirstCreatedAtAfter(ctx, watermark)
  1174. if err != nil {
  1175. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] outbox pending event read failed: %v", err)
  1176. return
  1177. }
  1178. var lag time.Duration
  1179. if ok && !oldestCreatedAt.IsZero() {
  1180. lag = now.Sub(oldestCreatedAt)
  1181. }
  1182. lagSeconds := int(lag.Seconds())
  1183. lagWarning := ok && !oldestCreatedAt.IsZero() &&
  1184. s.cfg.Gateway.Scheduling.OutboxLagWarnSeconds > 0 &&
  1185. lagSeconds >= s.cfg.Gateway.Scheduling.OutboxLagWarnSeconds
  1186. lagDegraded := ok && !oldestCreatedAt.IsZero() &&
  1187. s.cfg.Gateway.Scheduling.OutboxLagRebuildSeconds > 0 &&
  1188. lagSeconds >= s.cfg.Gateway.Scheduling.OutboxLagRebuildSeconds
  1189. backlogThreshold := s.cfg.Gateway.Scheduling.OutboxBacklogRebuildRows
  1190. backlogKnown := true
  1191. var backlog int64
  1192. if backlogThreshold > 0 {
  1193. maxID, maxErr := s.outboxRepo.MaxID(ctx)
  1194. if maxErr != nil {
  1195. backlogKnown = false
  1196. if s.shouldLogOutboxMaxIDError(now) {
  1197. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] outbox max id read failed: %v", maxErr)
  1198. }
  1199. } else {
  1200. backlog = maxID - watermark
  1201. }
  1202. }
  1203. backlogDegraded := backlogKnown && backlogThreshold > 0 && backlog >= int64(backlogThreshold)
  1204. // A successful rebuild latches the degraded episode until recovery. A failed
  1205. // rebuild remains retryable, but only after an exponentially backed-off
  1206. // cooldown so a one-second poll cannot create a rebuild storm.
  1207. logLagWarning := s.shouldLogOutboxLagWarning(lagWarning)
  1208. s.lagMu.Lock()
  1209. fullyRecovered := !lagDegraded && backlogKnown && !backlogDegraded
  1210. if fullyRecovered {
  1211. s.lagFailures = 0
  1212. s.outboxRebuildLatched = false
  1213. s.outboxRebuildFailures = 0
  1214. s.outboxRebuildRetryAt = time.Time{}
  1215. s.outboxRebuildRetryReason = ""
  1216. }
  1217. if s.outboxRebuildRetryReason != "" {
  1218. retryReasonActive := (s.outboxRebuildRetryReason == "outbox_lag" && lagDegraded) ||
  1219. (s.outboxRebuildRetryReason == "outbox_backlog" && (!backlogKnown || backlogDegraded))
  1220. if !retryReasonActive {
  1221. s.outboxRebuildFailures = 0
  1222. s.outboxRebuildRetryAt = time.Time{}
  1223. s.outboxRebuildRetryReason = ""
  1224. }
  1225. }
  1226. lagRetryPending := s.outboxRebuildRetryReason == "outbox_lag" && !s.outboxRebuildRetryAt.IsZero()
  1227. if lagDegraded {
  1228. if !s.outboxRebuildLatched && !s.outboxRebuildRunning && !lagRetryPending {
  1229. s.lagFailures++
  1230. }
  1231. } else {
  1232. s.lagFailures = 0
  1233. }
  1234. failures := s.lagFailures
  1235. lagReady := lagDegraded && failures >= s.cfg.Gateway.Scheduling.OutboxLagRebuildFailures
  1236. retryDue := s.outboxRebuildRetryReason != "" &&
  1237. !s.outboxRebuildRetryAt.IsZero() && !now.Before(s.outboxRebuildRetryAt)
  1238. reason := ""
  1239. lagCanPreemptRetry := lagReady && s.outboxRebuildRetryReason != "outbox_lag"
  1240. if !s.outboxRebuildLatched && !s.outboxRebuildRunning &&
  1241. (s.outboxRebuildRetryAt.IsZero() || retryDue || lagCanPreemptRetry) {
  1242. switch {
  1243. case lagReady || (retryDue && s.outboxRebuildRetryReason == "outbox_lag" && lagDegraded):
  1244. if s.outboxRebuildRetryReason != "" && s.outboxRebuildRetryReason != "outbox_lag" {
  1245. s.outboxRebuildFailures = 0
  1246. s.outboxRebuildRetryAt = time.Time{}
  1247. s.outboxRebuildRetryReason = ""
  1248. }
  1249. reason = "outbox_lag"
  1250. s.lagFailures = 0
  1251. case backlogDegraded && (s.outboxRebuildRetryReason == "" ||
  1252. (retryDue && s.outboxRebuildRetryReason == "outbox_backlog")):
  1253. reason = "outbox_backlog"
  1254. }
  1255. if reason != "" {
  1256. s.outboxRebuildRunning = true
  1257. }
  1258. }
  1259. s.lagMu.Unlock()
  1260. if logLagWarning {
  1261. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] outbox lag warning: %ds", lagSeconds)
  1262. }
  1263. if reason == "" {
  1264. return
  1265. }
  1266. var rebuildErr error
  1267. switch reason {
  1268. case "outbox_lag":
  1269. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] outbox lag rebuild triggered: lag=%s failures=%d", lag, failures)
  1270. rebuildErr = s.triggerFullRebuild(reason)
  1271. case "outbox_backlog":
  1272. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] outbox backlog rebuild triggered: backlog=%d", backlog)
  1273. rebuildErr = s.triggerFullRebuild(reason)
  1274. }
  1275. s.lagMu.Lock()
  1276. s.outboxRebuildRunning = false
  1277. if rebuildErr == nil {
  1278. s.outboxRebuildLatched = true
  1279. s.outboxRebuildFailures = 0
  1280. s.outboxRebuildRetryAt = time.Time{}
  1281. s.outboxRebuildRetryReason = ""
  1282. } else {
  1283. s.outboxRebuildLatched = false
  1284. s.outboxRebuildFailures++
  1285. s.outboxRebuildRetryAt = time.Now().Add(outboxRebuildRetryDelay(s.outboxRebuildFailures))
  1286. s.outboxRebuildRetryReason = reason
  1287. }
  1288. s.lagMu.Unlock()
  1289. if rebuildErr != nil {
  1290. logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] %s rebuild failed: %v", reason, rebuildErr)
  1291. }
  1292. }
  1293. func outboxRebuildRetryDelay(failures int) time.Duration {
  1294. delay := outboxRebuildRetryBaseDelay
  1295. for i := 1; i < failures && delay < outboxRebuildRetryMaxDelay; i++ {
  1296. delay *= 2
  1297. if delay >= outboxRebuildRetryMaxDelay {
  1298. return outboxRebuildRetryMaxDelay
  1299. }
  1300. }
  1301. return delay
  1302. }
  1303. func (s *SchedulerSnapshotService) clearOutboxDegradedEpisode() {
  1304. if s == nil {
  1305. return
  1306. }
  1307. s.lagMu.Lock()
  1308. if s.lagFailures != 0 || s.outboxRebuildLatched || s.outboxRebuildRunning ||
  1309. s.outboxRebuildFailures != 0 || !s.outboxRebuildRetryAt.IsZero() ||
  1310. s.outboxRebuildRetryReason != "" || s.outboxLagWarningActive {
  1311. s.lagFailures = 0
  1312. s.outboxRebuildLatched = false
  1313. s.outboxRebuildFailures = 0
  1314. s.outboxRebuildRetryAt = time.Time{}
  1315. s.outboxRebuildRetryReason = ""
  1316. s.outboxLagWarningActive = false
  1317. }
  1318. s.lagMu.Unlock()
  1319. }
  1320. func (s *SchedulerSnapshotService) shouldLogOutboxMaxIDError(now time.Time) bool {
  1321. s.lagMu.Lock()
  1322. defer s.lagMu.Unlock()
  1323. if !s.outboxMaxIDErrorLastLoggedAt.IsZero() && now.Sub(s.outboxMaxIDErrorLastLoggedAt) < outboxMaxIDErrorLogSampleInterval {
  1324. return false
  1325. }
  1326. s.outboxMaxIDErrorLastLoggedAt = now
  1327. return true
  1328. }
  1329. func (s *SchedulerSnapshotService) shouldLogOutboxLagWarning(active bool) bool {
  1330. s.lagMu.Lock()
  1331. defer s.lagMu.Unlock()
  1332. shouldLog := active && !s.outboxLagWarningActive
  1333. s.outboxLagWarningActive = active
  1334. return shouldLog
  1335. }
  1336. func (s *SchedulerSnapshotService) loadAccountsFromDB(ctx context.Context, bucket SchedulerBucket, useMixed bool) ([]Account, error) {
  1337. if s.accountRepo == nil {
  1338. return nil, ErrSchedulerCacheNotReady
  1339. }
  1340. groupID := bucket.GroupID
  1341. if s.isRunModeSimple() {
  1342. groupID = 0
  1343. }
  1344. if useMixed {
  1345. platforms := []string{bucket.Platform, PlatformAntigravity}
  1346. var accounts []Account
  1347. var err error
  1348. if groupID > 0 {
  1349. accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatforms(ctx, groupID, platforms)
  1350. } else if s.isRunModeSimple() {
  1351. accounts, err = s.accountRepo.ListSchedulableByPlatforms(ctx, platforms)
  1352. } else {
  1353. accounts, err = s.accountRepo.ListSchedulableUngroupedByPlatforms(ctx, platforms)
  1354. }
  1355. if err != nil {
  1356. return nil, err
  1357. }
  1358. filtered := make([]Account, 0, len(accounts))
  1359. for _, acc := range accounts {
  1360. if acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() {
  1361. continue
  1362. }
  1363. filtered = append(filtered, acc)
  1364. }
  1365. return filtered, nil
  1366. }
  1367. if groupID > 0 {
  1368. return s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, groupID, bucket.Platform)
  1369. }
  1370. if s.isRunModeSimple() {
  1371. return s.accountRepo.ListSchedulableByPlatform(ctx, bucket.Platform)
  1372. }
  1373. return s.accountRepo.ListSchedulableUngroupedByPlatform(ctx, bucket.Platform)
  1374. }
  1375. func (s *SchedulerSnapshotService) loadAccountsForRebuild(
  1376. ctx context.Context,
  1377. bucket SchedulerBucket,
  1378. queries *schedulerAccountQueryCache,
  1379. ) ([]Account, error) {
  1380. key, cacheable := schedulerAccountQueryKeyForBucket(bucket)
  1381. if queries == nil || !cacheable {
  1382. return s.loadAccountsFromDB(ctx, bucket, bucket.Mode == SchedulerModeMixed)
  1383. }
  1384. if accounts, ok := queries.accounts[key]; ok {
  1385. return accounts, nil
  1386. }
  1387. if queries.remaining[key] <= 1 {
  1388. return s.loadAccountsFromDB(ctx, bucket, false)
  1389. }
  1390. accounts, err := s.loadAccountsFromDB(ctx, bucket, false)
  1391. if err != nil {
  1392. return nil, err
  1393. }
  1394. queries.accounts[key] = accounts
  1395. return accounts, nil
  1396. }
  1397. func (s *SchedulerSnapshotService) bucketFor(groupID *int64, platform string, mode string) SchedulerBucket {
  1398. return SchedulerBucket{
  1399. GroupID: s.normalizeGroupID(groupID),
  1400. Platform: platform,
  1401. Mode: mode,
  1402. }
  1403. }
  1404. func (s *SchedulerSnapshotService) normalizeGroupID(groupID *int64) int64 {
  1405. if s.isRunModeSimple() {
  1406. return 0
  1407. }
  1408. if groupID == nil || *groupID <= 0 {
  1409. return 0
  1410. }
  1411. return *groupID
  1412. }
  1413. func (s *SchedulerSnapshotService) normalizeGroupIDs(groupIDs []int64) []int64 {
  1414. if s.isRunModeSimple() {
  1415. return []int64{0}
  1416. }
  1417. if len(groupIDs) == 0 {
  1418. return []int64{0}
  1419. }
  1420. seen := make(map[int64]struct{}, len(groupIDs))
  1421. out := make([]int64, 0, len(groupIDs))
  1422. for _, id := range groupIDs {
  1423. if id <= 0 {
  1424. continue
  1425. }
  1426. if _, ok := seen[id]; ok {
  1427. continue
  1428. }
  1429. seen[id] = struct{}{}
  1430. out = append(out, id)
  1431. }
  1432. if len(out) == 0 {
  1433. return []int64{0}
  1434. }
  1435. return out
  1436. }
  1437. func (s *SchedulerSnapshotService) resolveMode(platform string, hasForcePlatform bool) string {
  1438. if hasForcePlatform {
  1439. return SchedulerModeForced
  1440. }
  1441. if platform == PlatformAnthropic || platform == PlatformGemini {
  1442. return SchedulerModeMixed
  1443. }
  1444. return SchedulerModeSingle
  1445. }
  1446. func (s *SchedulerSnapshotService) guardFallback(ctx context.Context) error {
  1447. if s.cfg == nil || s.cfg.Gateway.Scheduling.DbFallbackEnabled {
  1448. if s.fallbackLimit == nil || s.fallbackLimit.Allow() {
  1449. return nil
  1450. }
  1451. return ErrSchedulerFallbackLimited
  1452. }
  1453. return ErrSchedulerCacheNotReady
  1454. }
  1455. func (s *SchedulerSnapshotService) withFallbackTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
  1456. if s.cfg == nil || s.cfg.Gateway.Scheduling.DbFallbackTimeoutSeconds <= 0 {
  1457. return context.WithCancel(ctx)
  1458. }
  1459. timeout := time.Duration(s.cfg.Gateway.Scheduling.DbFallbackTimeoutSeconds) * time.Second
  1460. if deadline, ok := ctx.Deadline(); ok {
  1461. remaining := time.Until(deadline)
  1462. if remaining <= 0 {
  1463. return context.WithCancel(ctx)
  1464. }
  1465. if remaining < timeout {
  1466. timeout = remaining
  1467. }
  1468. }
  1469. return context.WithTimeout(ctx, timeout)
  1470. }
  1471. func (s *SchedulerSnapshotService) isRunModeSimple() bool {
  1472. return s.cfg != nil && s.cfg.RunMode == config.RunModeSimple
  1473. }
  1474. func (s *SchedulerSnapshotService) outboxPollInterval() time.Duration {
  1475. if s.cfg == nil {
  1476. return time.Second
  1477. }
  1478. sec := s.cfg.Gateway.Scheduling.OutboxPollIntervalSeconds
  1479. if sec <= 0 {
  1480. return time.Second
  1481. }
  1482. return time.Duration(sec) * time.Second
  1483. }
  1484. func (s *SchedulerSnapshotService) fullRebuildInterval() time.Duration {
  1485. if s.cfg == nil {
  1486. return 0
  1487. }
  1488. sec := s.cfg.Gateway.Scheduling.FullRebuildIntervalSeconds
  1489. if sec <= 0 {
  1490. return 0
  1491. }
  1492. return time.Duration(sec) * time.Second
  1493. }
  1494. func dedupeBuckets(in []SchedulerBucket) []SchedulerBucket {
  1495. seen := make(map[string]struct{}, len(in))
  1496. out := make([]SchedulerBucket, 0, len(in))
  1497. for _, bucket := range in {
  1498. key := bucket.String()
  1499. if _, ok := seen[key]; ok {
  1500. continue
  1501. }
  1502. seen[key] = struct{}{}
  1503. out = append(out, bucket)
  1504. }
  1505. return out
  1506. }
  1507. func derefAccounts(accounts []*Account) []Account {
  1508. if len(accounts) == 0 {
  1509. return []Account{}
  1510. }
  1511. out := make([]Account, 0, len(accounts))
  1512. for _, account := range accounts {
  1513. if account == nil {
  1514. continue
  1515. }
  1516. out = append(out, *account)
  1517. }
  1518. return out
  1519. }
  1520. func parseInt64Slice(value any) []int64 {
  1521. raw, ok := value.([]any)
  1522. if !ok {
  1523. return nil
  1524. }
  1525. out := make([]int64, 0, len(raw))
  1526. for _, item := range raw {
  1527. if v, ok := toInt64(item); ok && v > 0 {
  1528. out = append(out, v)
  1529. }
  1530. }
  1531. return out
  1532. }
  1533. func toInt64(value any) (int64, bool) {
  1534. switch v := value.(type) {
  1535. case float64:
  1536. return int64(v), true
  1537. case int64:
  1538. return v, true
  1539. case int:
  1540. return int64(v), true
  1541. case json.Number:
  1542. parsed, err := strconv.ParseInt(v.String(), 10, 64)
  1543. return parsed, err == nil
  1544. default:
  1545. return 0, false
  1546. }
  1547. }
  1548. type fallbackLimiter struct {
  1549. maxQPS int
  1550. mu sync.Mutex
  1551. window time.Time
  1552. count int
  1553. }
  1554. func newFallbackLimiter(maxQPS int) *fallbackLimiter {
  1555. if maxQPS <= 0 {
  1556. return nil
  1557. }
  1558. return &fallbackLimiter{
  1559. maxQPS: maxQPS,
  1560. window: time.Now(),
  1561. }
  1562. }
  1563. func (l *fallbackLimiter) Allow() bool {
  1564. if l == nil || l.maxQPS <= 0 {
  1565. return true
  1566. }
  1567. l.mu.Lock()
  1568. defer l.mu.Unlock()
  1569. now := time.Now()
  1570. if now.Sub(l.window) >= time.Second {
  1571. l.window = now
  1572. l.count = 0
  1573. }
  1574. if l.count >= l.maxQPS {
  1575. return false
  1576. }
  1577. l.count++
  1578. return true
  1579. }