temp_unsched_cache.go 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. package repository
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "time"
  7. "github.com/Wei-Shaw/sub2api/internal/service"
  8. "github.com/redis/go-redis/v9"
  9. )
  10. const tempUnschedPrefix = "temp_unsched:account:"
  11. const openAIAPIKeyHealthFailurePrefix = "openai_apikey_health:"
  12. var openAIAPIKeyHealthFailureScript = redis.NewScript(`
  13. local key = KEYS[1]
  14. local sequence_key = key .. ':sequence'
  15. local now = redis.call('TIME')
  16. local now_ms = (tonumber(now[1]) * 1000) + math.floor(tonumber(now[2]) / 1000)
  17. local window_ms = tonumber(ARGV[1]) * 60 * 1000
  18. local threshold = tonumber(ARGV[2])
  19. local sequence = redis.call('INCR', sequence_key)
  20. redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ms - window_ms)
  21. redis.call('ZADD', key, now_ms, tostring(now_ms) .. ':' .. tostring(sequence))
  22. local count = redis.call('ZCARD', key)
  23. local ttl = math.max(60, (tonumber(ARGV[1]) + 1) * 60)
  24. redis.call('EXPIRE', key, ttl)
  25. redis.call('EXPIRE', sequence_key, ttl)
  26. if count >= threshold then
  27. redis.call('DEL', key, sequence_key)
  28. return {count, 1}
  29. end
  30. return {count, 0}
  31. `)
  32. var tempUnschedSetScript = redis.NewScript(`
  33. local key = KEYS[1]
  34. local new_until = tonumber(ARGV[1])
  35. local new_value = ARGV[2]
  36. local new_ttl = tonumber(ARGV[3])
  37. local existing = redis.call('GET', key)
  38. if existing then
  39. local ok, existing_data = pcall(cjson.decode, existing)
  40. if ok and existing_data and existing_data.until_unix then
  41. local existing_until = tonumber(existing_data.until_unix)
  42. if existing_until and new_until <= existing_until then
  43. return 0
  44. end
  45. end
  46. end
  47. redis.call('SET', key, new_value, 'EX', new_ttl)
  48. return 1
  49. `)
  50. type tempUnschedCache struct {
  51. rdb *redis.Client
  52. }
  53. func NewTempUnschedCache(rdb *redis.Client) service.TempUnschedCache {
  54. return &tempUnschedCache{rdb: rdb}
  55. }
  56. // SetTempUnsched 设置临时不可调度状态(只延长不缩短)
  57. func (c *tempUnschedCache) SetTempUnsched(ctx context.Context, accountID int64, state *service.TempUnschedState) error {
  58. key := fmt.Sprintf("%s%d", tempUnschedPrefix, accountID)
  59. stateJSON, err := json.Marshal(state)
  60. if err != nil {
  61. return fmt.Errorf("marshal state: %w", err)
  62. }
  63. ttl := time.Until(time.Unix(state.UntilUnix, 0))
  64. if ttl <= 0 {
  65. return nil // 已过期,不设置
  66. }
  67. ttlSeconds := int(ttl.Seconds())
  68. if ttlSeconds < 1 {
  69. ttlSeconds = 1
  70. }
  71. _, err = tempUnschedSetScript.Run(ctx, c.rdb, []string{key}, state.UntilUnix, string(stateJSON), ttlSeconds).Result()
  72. return err
  73. }
  74. // GetTempUnsched 获取临时不可调度状态
  75. func (c *tempUnschedCache) GetTempUnsched(ctx context.Context, accountID int64) (*service.TempUnschedState, error) {
  76. key := fmt.Sprintf("%s%d", tempUnschedPrefix, accountID)
  77. val, err := c.rdb.Get(ctx, key).Result()
  78. if err == redis.Nil {
  79. return nil, nil
  80. }
  81. if err != nil {
  82. return nil, err
  83. }
  84. var state service.TempUnschedState
  85. if err := json.Unmarshal([]byte(val), &state); err != nil {
  86. return nil, fmt.Errorf("unmarshal state: %w", err)
  87. }
  88. return &state, nil
  89. }
  90. // DeleteTempUnsched 删除临时不可调度状态
  91. func (c *tempUnschedCache) DeleteTempUnsched(ctx context.Context, accountID int64) error {
  92. key := fmt.Sprintf("%s%d", tempUnschedPrefix, accountID)
  93. return c.rdb.Del(ctx, key).Err()
  94. }
  95. func (c *tempUnschedCache) openAIAPIKeyHealthKey(accountID int64) string {
  96. // The hash tag keeps the rolling window and sequence key in one Redis
  97. // Cluster slot even though the Lua script derives the latter dynamically.
  98. return fmt.Sprintf("%s{%d}:failures", openAIAPIKeyHealthFailurePrefix, accountID)
  99. }
  100. func (c *tempUnschedCache) RecordOpenAIAPIKeyHealthFailure(ctx context.Context, accountID int64, windowMinutes, threshold int) (int64, bool, error) {
  101. if windowMinutes < 1 {
  102. windowMinutes = 1
  103. }
  104. if threshold < 1 {
  105. threshold = 1
  106. }
  107. result, err := openAIAPIKeyHealthFailureScript.Run(ctx, c.rdb, []string{c.openAIAPIKeyHealthKey(accountID)}, windowMinutes, threshold).Slice()
  108. if err != nil {
  109. return 0, false, fmt.Errorf("record OpenAI API key health failure: %w", err)
  110. }
  111. if len(result) != 2 {
  112. return 0, false, fmt.Errorf("record OpenAI API key health failure: unexpected result length %d", len(result))
  113. }
  114. count, countOK := result[0].(int64)
  115. tripped, trippedOK := result[1].(int64)
  116. if !countOK || !trippedOK {
  117. return 0, false, fmt.Errorf("record OpenAI API key health failure: unexpected result types %T/%T", result[0], result[1])
  118. }
  119. return count, tripped == 1, nil
  120. }