usage_cleanup_service.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "fmt"
  7. "log/slog"
  8. "net/http"
  9. "strings"
  10. "sync"
  11. "sync/atomic"
  12. "time"
  13. "github.com/Wei-Shaw/sub2api/internal/config"
  14. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  15. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  16. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  17. )
  18. const (
  19. usageCleanupWorkerName = "usage_cleanup_worker"
  20. )
  21. // UsageCleanupService 负责创建与执行使用记录清理任务
  22. type UsageCleanupService struct {
  23. repo UsageCleanupRepository
  24. timingWheel *TimingWheelService
  25. dashboard *DashboardAggregationService
  26. cfg *config.Config
  27. running int32
  28. startOnce sync.Once
  29. stopOnce sync.Once
  30. workerCtx context.Context
  31. workerCancel context.CancelFunc
  32. }
  33. func NewUsageCleanupService(repo UsageCleanupRepository, timingWheel *TimingWheelService, dashboard *DashboardAggregationService, cfg *config.Config) *UsageCleanupService {
  34. workerCtx, workerCancel := context.WithCancel(context.Background())
  35. return &UsageCleanupService{
  36. repo: repo,
  37. timingWheel: timingWheel,
  38. dashboard: dashboard,
  39. cfg: cfg,
  40. workerCtx: workerCtx,
  41. workerCancel: workerCancel,
  42. }
  43. }
  44. func describeUsageCleanupFilters(filters UsageCleanupFilters) string {
  45. var parts []string
  46. parts = append(parts, "start="+filters.StartTime.UTC().Format(time.RFC3339))
  47. parts = append(parts, "end="+filters.EndTime.UTC().Format(time.RFC3339))
  48. if filters.UserID != nil {
  49. parts = append(parts, fmt.Sprintf("user_id=%d", *filters.UserID))
  50. }
  51. if filters.APIKeyID != nil {
  52. parts = append(parts, fmt.Sprintf("api_key_id=%d", *filters.APIKeyID))
  53. }
  54. if filters.AccountID != nil {
  55. parts = append(parts, fmt.Sprintf("account_id=%d", *filters.AccountID))
  56. }
  57. if filters.GroupID != nil {
  58. parts = append(parts, fmt.Sprintf("group_id=%d", *filters.GroupID))
  59. }
  60. if filters.Model != nil {
  61. parts = append(parts, "model="+strings.TrimSpace(*filters.Model))
  62. }
  63. if filters.RequestType != nil {
  64. parts = append(parts, "request_type="+RequestTypeFromInt16(*filters.RequestType).String())
  65. }
  66. if filters.Stream != nil {
  67. parts = append(parts, fmt.Sprintf("stream=%t", *filters.Stream))
  68. }
  69. if filters.BillingType != nil {
  70. parts = append(parts, fmt.Sprintf("billing_type=%d", *filters.BillingType))
  71. }
  72. return strings.Join(parts, " ")
  73. }
  74. func (s *UsageCleanupService) Start() {
  75. if s == nil {
  76. return
  77. }
  78. if s.cfg != nil && !s.cfg.UsageCleanup.Enabled {
  79. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] not started (disabled)")
  80. return
  81. }
  82. if s.repo == nil || s.timingWheel == nil {
  83. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] not started (missing deps)")
  84. return
  85. }
  86. interval := s.workerInterval()
  87. s.startOnce.Do(func() {
  88. s.timingWheel.ScheduleRecurring(usageCleanupWorkerName, interval, s.runOnce)
  89. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] started (interval=%s max_range_days=%d batch_size=%d task_timeout=%s)", interval, s.maxRangeDays(), s.batchSize(), s.taskTimeout())
  90. })
  91. }
  92. func (s *UsageCleanupService) Stop() {
  93. if s == nil {
  94. return
  95. }
  96. s.stopOnce.Do(func() {
  97. if s.workerCancel != nil {
  98. s.workerCancel()
  99. }
  100. if s.timingWheel != nil {
  101. s.timingWheel.Cancel(usageCleanupWorkerName)
  102. }
  103. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] stopped")
  104. })
  105. }
  106. func (s *UsageCleanupService) ListTasks(ctx context.Context, params pagination.PaginationParams) ([]UsageCleanupTask, *pagination.PaginationResult, error) {
  107. if s == nil || s.repo == nil {
  108. return nil, nil, fmt.Errorf("cleanup service not ready")
  109. }
  110. return s.repo.ListTasks(ctx, params)
  111. }
  112. func (s *UsageCleanupService) CreateTask(ctx context.Context, filters UsageCleanupFilters, createdBy int64) (*UsageCleanupTask, error) {
  113. if s == nil || s.repo == nil {
  114. return nil, fmt.Errorf("cleanup service not ready")
  115. }
  116. if s.cfg != nil && !s.cfg.UsageCleanup.Enabled {
  117. return nil, infraerrors.New(http.StatusServiceUnavailable, "USAGE_CLEANUP_DISABLED", "usage cleanup is disabled")
  118. }
  119. if createdBy <= 0 {
  120. return nil, infraerrors.BadRequest("USAGE_CLEANUP_INVALID_CREATOR", "invalid creator")
  121. }
  122. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] create_task requested: operator=%d %s", createdBy, describeUsageCleanupFilters(filters))
  123. sanitizeUsageCleanupFilters(&filters)
  124. if err := s.validateFilters(filters); err != nil {
  125. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] create_task rejected: operator=%d err=%v %s", createdBy, err, describeUsageCleanupFilters(filters))
  126. return nil, err
  127. }
  128. task := &UsageCleanupTask{
  129. Status: UsageCleanupStatusPending,
  130. Filters: filters,
  131. CreatedBy: createdBy,
  132. }
  133. if err := s.repo.CreateTask(ctx, task); err != nil {
  134. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] create_task persist failed: operator=%d err=%v %s", createdBy, err, describeUsageCleanupFilters(filters))
  135. return nil, fmt.Errorf("create cleanup task: %w", err)
  136. }
  137. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] create_task persisted: task=%d operator=%d status=%s deleted_rows=%d %s", task.ID, createdBy, task.Status, task.DeletedRows, describeUsageCleanupFilters(filters))
  138. go s.runOnce()
  139. return task, nil
  140. }
  141. func (s *UsageCleanupService) runOnce() {
  142. svc := s
  143. if svc == nil {
  144. return
  145. }
  146. if !atomic.CompareAndSwapInt32(&svc.running, 0, 1) {
  147. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] run_once skipped: already_running=true")
  148. return
  149. }
  150. defer atomic.StoreInt32(&svc.running, 0)
  151. parent := context.Background()
  152. if svc.workerCtx != nil {
  153. parent = svc.workerCtx
  154. }
  155. ctx, cancel := context.WithTimeout(parent, svc.taskTimeout())
  156. defer cancel()
  157. task, err := svc.repo.ClaimNextPendingTask(ctx, int64(svc.taskTimeout().Seconds()))
  158. if err != nil {
  159. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] claim pending task failed: %v", err)
  160. return
  161. }
  162. if task == nil {
  163. slog.Debug("[UsageCleanup] run_once done: no_task=true")
  164. return
  165. }
  166. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] task claimed: task=%d status=%s created_by=%d deleted_rows=%d %s", task.ID, task.Status, task.CreatedBy, task.DeletedRows, describeUsageCleanupFilters(task.Filters))
  167. svc.executeTask(ctx, task)
  168. }
  169. func (s *UsageCleanupService) executeTask(ctx context.Context, task *UsageCleanupTask) {
  170. if task == nil {
  171. return
  172. }
  173. batchSize := s.batchSize()
  174. deletedTotal := task.DeletedRows
  175. start := time.Now()
  176. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] task started: task=%d batch_size=%d deleted_rows=%d %s", task.ID, batchSize, deletedTotal, describeUsageCleanupFilters(task.Filters))
  177. var batchNum int
  178. for {
  179. if ctx != nil && ctx.Err() != nil {
  180. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] task interrupted: task=%d err=%v", task.ID, ctx.Err())
  181. return
  182. }
  183. canceled, err := s.isTaskCanceled(ctx, task.ID)
  184. if err != nil {
  185. s.markTaskFailed(task.ID, deletedTotal, err)
  186. return
  187. }
  188. if canceled {
  189. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] task canceled: task=%d deleted_rows=%d duration=%s", task.ID, deletedTotal, time.Since(start))
  190. return
  191. }
  192. batchNum++
  193. deleted, err := s.repo.DeleteUsageLogsBatch(ctx, task.Filters, batchSize)
  194. if err != nil {
  195. if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
  196. // 任务被中断(例如服务停止/超时),保持 running 状态,后续通过 stale reclaim 续跑。
  197. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] task interrupted: task=%d err=%v", task.ID, err)
  198. return
  199. }
  200. s.markTaskFailed(task.ID, deletedTotal, err)
  201. return
  202. }
  203. deletedTotal += deleted
  204. if deleted > 0 {
  205. updateCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  206. if err := s.repo.UpdateTaskProgress(updateCtx, task.ID, deletedTotal); err != nil {
  207. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] task progress update failed: task=%d deleted_rows=%d err=%v", task.ID, deletedTotal, err)
  208. }
  209. cancel()
  210. }
  211. if batchNum <= 3 || batchNum%20 == 0 || deleted < int64(batchSize) {
  212. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] task batch done: task=%d batch=%d deleted=%d deleted_total=%d", task.ID, batchNum, deleted, deletedTotal)
  213. }
  214. if deleted == 0 || deleted < int64(batchSize) {
  215. break
  216. }
  217. }
  218. updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  219. defer cancel()
  220. if err := s.repo.MarkTaskSucceeded(updateCtx, task.ID, deletedTotal); err != nil {
  221. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] update task succeeded failed: task=%d err=%v", task.ID, err)
  222. } else {
  223. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] task succeeded: task=%d deleted_rows=%d duration=%s", task.ID, deletedTotal, time.Since(start))
  224. }
  225. if s.dashboard != nil {
  226. if err := s.dashboard.TriggerRecomputeRange(task.Filters.StartTime, task.Filters.EndTime); err != nil {
  227. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] trigger dashboard recompute failed: task=%d err=%v", task.ID, err)
  228. } else {
  229. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] trigger dashboard recompute: task=%d start=%s end=%s", task.ID, task.Filters.StartTime.UTC().Format(time.RFC3339), task.Filters.EndTime.UTC().Format(time.RFC3339))
  230. }
  231. }
  232. }
  233. func (s *UsageCleanupService) markTaskFailed(taskID int64, deletedRows int64, err error) {
  234. msg := strings.TrimSpace(err.Error())
  235. if len(msg) > 500 {
  236. msg = msg[:500]
  237. }
  238. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] task failed: task=%d deleted_rows=%d err=%s", taskID, deletedRows, msg)
  239. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  240. defer cancel()
  241. if updateErr := s.repo.MarkTaskFailed(ctx, taskID, deletedRows, msg); updateErr != nil {
  242. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] update task failed failed: task=%d err=%v", taskID, updateErr)
  243. }
  244. }
  245. func (s *UsageCleanupService) isTaskCanceled(ctx context.Context, taskID int64) (bool, error) {
  246. if s == nil || s.repo == nil {
  247. return false, fmt.Errorf("cleanup service not ready")
  248. }
  249. checkCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  250. defer cancel()
  251. status, err := s.repo.GetTaskStatus(checkCtx, taskID)
  252. if err != nil {
  253. if errors.Is(err, sql.ErrNoRows) {
  254. return false, nil
  255. }
  256. return false, err
  257. }
  258. if status == UsageCleanupStatusCanceled {
  259. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] task cancel detected: task=%d", taskID)
  260. }
  261. return status == UsageCleanupStatusCanceled, nil
  262. }
  263. func (s *UsageCleanupService) validateFilters(filters UsageCleanupFilters) error {
  264. if filters.StartTime.IsZero() || filters.EndTime.IsZero() {
  265. return infraerrors.BadRequest("USAGE_CLEANUP_MISSING_RANGE", "start_date and end_date are required")
  266. }
  267. if filters.EndTime.Before(filters.StartTime) {
  268. return infraerrors.BadRequest("USAGE_CLEANUP_INVALID_RANGE", "end_date must be after start_date")
  269. }
  270. maxDays := s.maxRangeDays()
  271. if maxDays > 0 {
  272. delta := filters.EndTime.Sub(filters.StartTime)
  273. if delta > time.Duration(maxDays)*24*time.Hour {
  274. return infraerrors.BadRequest("USAGE_CLEANUP_RANGE_TOO_LARGE", fmt.Sprintf("date range exceeds %d days", maxDays))
  275. }
  276. }
  277. return nil
  278. }
  279. func (s *UsageCleanupService) CancelTask(ctx context.Context, taskID int64, canceledBy int64) error {
  280. if s == nil || s.repo == nil {
  281. return fmt.Errorf("cleanup service not ready")
  282. }
  283. if s.cfg != nil && !s.cfg.UsageCleanup.Enabled {
  284. return infraerrors.New(http.StatusServiceUnavailable, "USAGE_CLEANUP_DISABLED", "usage cleanup is disabled")
  285. }
  286. if canceledBy <= 0 {
  287. return infraerrors.BadRequest("USAGE_CLEANUP_INVALID_CANCELLER", "invalid canceller")
  288. }
  289. status, err := s.repo.GetTaskStatus(ctx, taskID)
  290. if err != nil {
  291. if errors.Is(err, sql.ErrNoRows) {
  292. return infraerrors.New(http.StatusNotFound, "USAGE_CLEANUP_TASK_NOT_FOUND", "cleanup task not found")
  293. }
  294. return err
  295. }
  296. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] cancel_task requested: task=%d operator=%d status=%s", taskID, canceledBy, status)
  297. if status == UsageCleanupStatusCanceled {
  298. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] cancel_task idempotent hit: task=%d operator=%d", taskID, canceledBy)
  299. return nil
  300. }
  301. if status != UsageCleanupStatusPending && status != UsageCleanupStatusRunning {
  302. return infraerrors.New(http.StatusConflict, "USAGE_CLEANUP_CANCEL_CONFLICT", "cleanup task cannot be canceled in current status")
  303. }
  304. ok, err := s.repo.CancelTask(ctx, taskID, canceledBy)
  305. if err != nil {
  306. return err
  307. }
  308. if !ok {
  309. // 状态可能并发改变
  310. currentStatus, getErr := s.repo.GetTaskStatus(ctx, taskID)
  311. if getErr == nil && currentStatus == UsageCleanupStatusCanceled {
  312. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] cancel_task idempotent race hit: task=%d operator=%d", taskID, canceledBy)
  313. return nil
  314. }
  315. return infraerrors.New(http.StatusConflict, "USAGE_CLEANUP_CANCEL_CONFLICT", "cleanup task cannot be canceled in current status")
  316. }
  317. logger.LegacyPrintf("service.usage_cleanup", "[UsageCleanup] cancel_task done: task=%d operator=%d", taskID, canceledBy)
  318. return nil
  319. }
  320. func sanitizeUsageCleanupFilters(filters *UsageCleanupFilters) {
  321. if filters == nil {
  322. return
  323. }
  324. if filters.UserID != nil && *filters.UserID <= 0 {
  325. filters.UserID = nil
  326. }
  327. if filters.APIKeyID != nil && *filters.APIKeyID <= 0 {
  328. filters.APIKeyID = nil
  329. }
  330. if filters.AccountID != nil && *filters.AccountID <= 0 {
  331. filters.AccountID = nil
  332. }
  333. if filters.GroupID != nil && *filters.GroupID <= 0 {
  334. filters.GroupID = nil
  335. }
  336. if filters.Model != nil {
  337. model := strings.TrimSpace(*filters.Model)
  338. if model == "" {
  339. filters.Model = nil
  340. } else {
  341. filters.Model = &model
  342. }
  343. }
  344. if filters.RequestType != nil {
  345. requestType := RequestType(*filters.RequestType)
  346. if !requestType.IsValid() {
  347. filters.RequestType = nil
  348. } else {
  349. value := int16(requestType.Normalize())
  350. filters.RequestType = &value
  351. filters.Stream = nil
  352. }
  353. }
  354. if filters.BillingType != nil && *filters.BillingType < 0 {
  355. filters.BillingType = nil
  356. }
  357. }
  358. func (s *UsageCleanupService) maxRangeDays() int {
  359. if s == nil || s.cfg == nil {
  360. return 31
  361. }
  362. if s.cfg.UsageCleanup.MaxRangeDays > 0 {
  363. return s.cfg.UsageCleanup.MaxRangeDays
  364. }
  365. return 31
  366. }
  367. func (s *UsageCleanupService) batchSize() int {
  368. if s == nil || s.cfg == nil {
  369. return 5000
  370. }
  371. if s.cfg.UsageCleanup.BatchSize > 0 {
  372. return s.cfg.UsageCleanup.BatchSize
  373. }
  374. return 5000
  375. }
  376. func (s *UsageCleanupService) workerInterval() time.Duration {
  377. if s == nil || s.cfg == nil {
  378. return 10 * time.Second
  379. }
  380. if s.cfg.UsageCleanup.WorkerIntervalSeconds > 0 {
  381. return time.Duration(s.cfg.UsageCleanup.WorkerIntervalSeconds) * time.Second
  382. }
  383. return 10 * time.Second
  384. }
  385. func (s *UsageCleanupService) taskTimeout() time.Duration {
  386. if s == nil || s.cfg == nil {
  387. return 30 * time.Minute
  388. }
  389. if s.cfg.UsageCleanup.TaskTimeoutSeconds > 0 {
  390. return time.Duration(s.cfg.UsageCleanup.TaskTimeoutSeconds) * time.Second
  391. }
  392. return 30 * time.Minute
  393. }