intelligent_test_queue.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. package repository
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "database/sql"
  6. "encoding/hex"
  7. "encoding/json"
  8. "errors"
  9. "fmt"
  10. "sort"
  11. "strings"
  12. "time"
  13. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  14. "github.com/Wei-Shaw/sub2api/internal/service"
  15. "github.com/google/uuid"
  16. "github.com/lib/pq"
  17. )
  18. func (r *intelligentTestRepository) Enqueue(ctx context.Context, actor int64, req service.IntelligentTestEnqueue) (*service.IntelligentTestEnqueued, error) {
  19. accountIDs := append([]int64{}, req.AccountIDs...)
  20. types := append([]string{}, req.TestTypes...)
  21. sort.Slice(accountIDs, func(i, j int) bool { return accountIDs[i] < accountIDs[j] })
  22. sort.Strings(types)
  23. payload, _ := json.Marshal(struct {
  24. Accounts []int64
  25. Types []string
  26. Models map[string]string
  27. }{accountIDs, types, req.Models})
  28. sum := sha256.Sum256(payload)
  29. fingerprint := hex.EncodeToString(sum[:])
  30. tx, err := r.db.BeginTx(ctx, nil)
  31. if err != nil {
  32. return nil, err
  33. }
  34. defer tx.Rollback()
  35. // Short transaction lock coordinates enqueue/capacity checks across replicas.
  36. if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(247000)`); err != nil {
  37. return nil, err
  38. }
  39. var existingFingerprint string
  40. var ids []int64
  41. err = tx.QueryRowContext(ctx, `SELECT fingerprint,record_ids FROM intelligent_test_requests WHERE actor_id=$1 AND request_key=$2`, actor, req.IdempotencyKey).Scan(&existingFingerprint, pq.Array(&ids))
  42. if err == nil {
  43. if existingFingerprint != fingerprint {
  44. return nil, service.ErrIntelligentTestConflict
  45. }
  46. rows, err := tx.QueryContext(ctx, `SELECT `+intelligentRecordColumns+` FROM account_tests t WHERE id=ANY($1) ORDER BY id`, pq.Array(ids))
  47. if err != nil {
  48. return nil, err
  49. }
  50. records, err := readIntelligentRecords(rows)
  51. if err != nil {
  52. return nil, err
  53. }
  54. for _, record := range records {
  55. compactIntelligentRecord(record)
  56. }
  57. return &service.IntelligentTestEnqueued{Records: records, Reused: true, ReusedCount: len(records)}, tx.Commit()
  58. }
  59. if !errors.Is(err, sql.ErrNoRows) {
  60. return nil, err
  61. }
  62. var queued int
  63. err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM account_tests WHERE status IN ('queued','running')`).Scan(&queued)
  64. if err != nil {
  65. return nil, err
  66. }
  67. settings := map[string]service.IntelligentTestConfig{}
  68. for _, kind := range types {
  69. var raw []byte
  70. var enabled bool
  71. if err := tx.QueryRowContext(ctx, `SELECT enabled,config FROM test_settings WHERE test_type=$1 FOR SHARE`, kind).Scan(&enabled, &raw); err != nil {
  72. if errors.Is(err, sql.ErrNoRows) {
  73. return nil, infraerrors.BadRequest("INTELLIGENT_TEST_UNKNOWN_TYPE", "unknown test type")
  74. }
  75. return nil, err
  76. }
  77. if !enabled {
  78. return nil, infraerrors.BadRequest("INTELLIGENT_TEST_DISABLED", "selected test is disabled")
  79. }
  80. var cfg service.IntelligentTestConfig
  81. if err := json.Unmarshal(raw, &cfg); err != nil {
  82. return nil, err
  83. }
  84. settings[kind] = cfg
  85. }
  86. out := &service.IntelligentTestEnqueued{Records: []*service.IntelligentTestRecord{}}
  87. for _, id := range accountIDs {
  88. var protected bool
  89. err := tx.QueryRowContext(ctx, `SELECT `+intelligentProtectionSQL+` FROM accounts a WHERE a.id=$1 AND a.deleted_at IS NULL FOR SHARE`, id).Scan(&protected)
  90. if errors.Is(err, sql.ErrNoRows) {
  91. return nil, infraerrors.BadRequest("INTELLIGENT_TEST_ACCOUNT_MISSING", "an account does not exist")
  92. }
  93. if err != nil {
  94. return nil, err
  95. }
  96. for _, kind := range types {
  97. cfg := settings[kind]
  98. cfg.Execution = nil
  99. if override := strings.TrimSpace(req.Models[kind]); override != "" {
  100. cfg.Model = override
  101. }
  102. active, activeErr := scanIntelligentRecord(tx.QueryRowContext(ctx, `SELECT `+intelligentRecordColumns+` FROM account_tests t WHERE account_id=$1 AND test_type=$2 AND status IN ('queued','running') FOR UPDATE`, id, kind))
  103. if activeErr == nil {
  104. if !service.SameIntelligentTestConfig(*active.ConfigSnapshot, cfg) {
  105. return nil, infraerrors.Conflict("INTELLIGENT_TEST_ACTIVE_CONFLICT", fmt.Sprintf("账号 %d 的 %s 已有不同模型或配置的任务 #%d;请等待完成或取消排队任务", id, kind, active.ID))
  106. }
  107. compactIntelligentRecord(active)
  108. out.Records = append(out.Records, active)
  109. ids = append(ids, active.ID)
  110. out.Reused = true
  111. out.ReusedCount++
  112. continue
  113. }
  114. if !errors.Is(activeErr, service.ErrIntelligentTestNotFound) {
  115. return nil, activeErr
  116. }
  117. if queued+out.CreatedCount >= 2000 {
  118. return nil, infraerrors.TooManyRequests("INTELLIGENT_TEST_QUEUE_FULL", "测试队列已满,请稍后重试")
  119. }
  120. cfgJSON, _ := json.Marshal(cfg)
  121. var testID int64
  122. err := tx.QueryRowContext(ctx, `INSERT INTO account_tests(account_id,test_type,input,model,anti_degradation,config_snapshot,requested_by) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id`, id, kind, cfg.Prompt, cfg.Model, protected, string(cfgJSON), actor).Scan(&testID)
  123. if err != nil {
  124. return nil, err
  125. }
  126. record, err := scanIntelligentRecord(tx.QueryRowContext(ctx, `SELECT `+intelligentRecordColumns+` FROM account_tests t WHERE id=$1`, testID))
  127. if err != nil {
  128. return nil, err
  129. }
  130. compactIntelligentRecord(record)
  131. out.Records = append(out.Records, record)
  132. ids = append(ids, testID)
  133. out.CreatedCount++
  134. }
  135. }
  136. _, err = tx.ExecContext(ctx, `INSERT INTO intelligent_test_requests(actor_id,request_key,fingerprint,record_ids) VALUES($1,$2,$3,$4)`, actor, req.IdempotencyKey, fingerprint, pq.Array(ids))
  137. if err != nil {
  138. return nil, err
  139. }
  140. if err := insertIntelligentAudit(ctx, tx, actor, "admin.intelligent_tests.enqueue", map[string]any{"account_ids": accountIDs, "test_types": types, "record_ids": ids, "count": len(ids), "idempotency_key": req.IdempotencyKey}); err != nil {
  141. return nil, err
  142. }
  143. return out, tx.Commit()
  144. }
  145. func (r *intelligentTestRepository) Claim(ctx context.Context) (*service.IntelligentTestRecord, error) {
  146. tx, err := r.db.BeginTx(ctx, nil)
  147. if err != nil {
  148. return nil, err
  149. }
  150. defer tx.Rollback()
  151. if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(247001)`); err != nil {
  152. return nil, err
  153. }
  154. // An interrupted upstream call might already be billed. Never replay it automatically.
  155. _, err = tx.ExecContext(ctx, `UPDATE account_tests SET status='failed',error_message='服务中断或执行租约过期;请管理员手动重试,避免重复计费',finished_at=NOW(),duration_ms=GREATEST(0,(EXTRACT(EPOCH FROM (NOW()-started_at))*1000)::bigint),lease_token=NULL,lease_until=NULL WHERE status='running' AND lease_until<NOW()`)
  156. if err != nil {
  157. return nil, err
  158. }
  159. _, err = tx.ExecContext(ctx, `UPDATE account_tests t SET status='cancelled',error_message='执行前测试已停用或账号已删除',finished_at=NOW() WHERE t.status='queued' AND (NOT EXISTS(SELECT 1 FROM accounts a WHERE a.id=t.account_id AND a.deleted_at IS NULL) OR NOT EXISTS(SELECT 1 FROM test_settings s WHERE s.test_type=t.test_type AND s.enabled))`)
  160. if err != nil {
  161. return nil, err
  162. }
  163. token := uuid.NewString()
  164. record, err := scanIntelligentRecord(tx.QueryRowContext(ctx, `WITH picked AS(SELECT q.id FROM account_tests q WHERE q.status='queued' AND q.available_at<=NOW() AND NOT EXISTS(SELECT 1 FROM account_tests running WHERE running.account_id=q.account_id AND running.status='running') AND (SELECT COUNT(*) FROM account_tests WHERE status='running')<4 ORDER BY q.available_at,q.id FOR UPDATE SKIP LOCKED LIMIT 1) UPDATE account_tests t SET status='running',queue_reason='',started_at=NOW(),lease_token=$1,lease_until=NOW()+make_interval(secs=>LEAST(600,GREATEST(30,(t.config_snapshot->>'timeout_seconds')::integer))+90),anti_degradation=`+`(SELECT `+intelligentProtectionSQL+` FROM accounts a WHERE a.id=t.account_id)`+` FROM picked WHERE t.id=picked.id RETURNING `+intelligentRecordColumns, token))
  165. if errors.Is(err, service.ErrIntelligentTestNotFound) {
  166. return nil, tx.Commit()
  167. }
  168. if err != nil {
  169. return nil, err
  170. }
  171. return record, tx.Commit()
  172. }
  173. func (r *intelligentTestRepository) Finish(ctx context.Context, record *service.IntelligentTestRecord) error {
  174. if record.Status == "queued" || record.Status == "running" || record.Status == "" {
  175. return fmt.Errorf("cannot persist nonterminal test result")
  176. }
  177. eval, err := json.Marshal(record.Evaluation)
  178. if err != nil {
  179. return err
  180. }
  181. if string(eval) == "null" {
  182. eval = []byte("{}")
  183. }
  184. cfgJSON, err := json.Marshal(record.ConfigSnapshot)
  185. if err != nil {
  186. return err
  187. }
  188. result, err := r.db.ExecContext(ctx, `UPDATE account_tests SET status=$3,score=$4,result=$5,result_image=$6,raw_response=$7,raw_truncated=$8,error_message=$9,duration_ms=$10,model=$11,config_snapshot=COALESCE($12::jsonb,config_snapshot),evaluation=$13,anti_degradation=$14,finished_at=NOW(),lease_token=NULL,lease_until=NULL WHERE id=$1 AND status='running' AND lease_token=$2`, record.ID, record.LeaseToken, record.Status, record.Score, record.Result, record.ResultImage, record.RawResponse, record.RawTruncated, record.ErrorMessage, record.DurationMS, record.Model, string(cfgJSON), string(eval), record.AntiDegradation)
  189. if err != nil {
  190. return err
  191. }
  192. n, err := result.RowsAffected()
  193. if err != nil {
  194. return err
  195. }
  196. if n != 1 {
  197. return fmt.Errorf("test %d lost execution lease", record.ID)
  198. }
  199. now := time.Now().UTC()
  200. record.FinishedAt = &now
  201. return nil
  202. }