ops_concurrency.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. package service
  2. import (
  3. "context"
  4. "log"
  5. "time"
  6. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  7. )
  8. const (
  9. opsAccountsPageSize = 100
  10. opsConcurrencyBatchChunkSize = 200
  11. )
  12. type opsAccountStatsRepository interface {
  13. ListOpsAccountsForStats(ctx context.Context, platformFilter string, groupIDFilter *int64) ([]Account, error)
  14. }
  15. func (s *OpsService) listAllAccountsForOps(ctx context.Context, platformFilter string, groupIDFilter *int64) ([]Account, error) {
  16. if s == nil || s.accountRepo == nil {
  17. return []Account{}, nil
  18. }
  19. if repo, ok := s.accountRepo.(opsAccountStatsRepository); ok {
  20. return repo.ListOpsAccountsForStats(ctx, platformFilter, groupIDFilter)
  21. }
  22. out := make([]Account, 0, 128)
  23. page := 1
  24. groupID := int64(0)
  25. if groupIDFilter != nil {
  26. groupID = *groupIDFilter
  27. }
  28. for {
  29. accounts, pageInfo, err := s.accountRepo.ListWithFilters(ctx, pagination.PaginationParams{
  30. Page: page,
  31. PageSize: opsAccountsPageSize,
  32. }, platformFilter, "", "", "", groupID, "")
  33. if err != nil {
  34. return nil, err
  35. }
  36. if len(accounts) == 0 {
  37. break
  38. }
  39. out = append(out, accounts...)
  40. if pageInfo != nil && int64(len(out)) >= pageInfo.Total {
  41. break
  42. }
  43. if len(accounts) < opsAccountsPageSize {
  44. break
  45. }
  46. page++
  47. if page > 10_000 {
  48. log.Printf("[Ops] listAllAccountsForOps: aborting after too many pages (platform=%q)", platformFilter)
  49. break
  50. }
  51. }
  52. return out, nil
  53. }
  54. func (s *OpsService) getAccountsLoadMapBestEffort(ctx context.Context, accounts []Account) map[int64]*AccountLoadInfo {
  55. if s == nil || s.concurrencyService == nil {
  56. return map[int64]*AccountLoadInfo{}
  57. }
  58. if len(accounts) == 0 {
  59. return map[int64]*AccountLoadInfo{}
  60. }
  61. // De-duplicate IDs (and keep the max concurrency to avoid under-reporting).
  62. unique := make(map[int64]int, len(accounts))
  63. for _, acc := range accounts {
  64. if acc.ID <= 0 {
  65. continue
  66. }
  67. lf := acc.EffectiveLoadFactor()
  68. if prev, ok := unique[acc.ID]; !ok || lf > prev {
  69. unique[acc.ID] = lf
  70. }
  71. }
  72. batch := make([]AccountWithConcurrency, 0, len(unique))
  73. for id, maxConc := range unique {
  74. batch = append(batch, AccountWithConcurrency{
  75. ID: id,
  76. MaxConcurrency: maxConc,
  77. })
  78. }
  79. out := make(map[int64]*AccountLoadInfo, len(batch))
  80. for i := 0; i < len(batch); i += opsConcurrencyBatchChunkSize {
  81. end := i + opsConcurrencyBatchChunkSize
  82. if end > len(batch) {
  83. end = len(batch)
  84. }
  85. part, err := s.concurrencyService.GetAccountsLoadBatch(ctx, batch[i:end])
  86. if err != nil {
  87. // Best-effort: return zeros rather than failing the ops UI.
  88. log.Printf("[Ops] GetAccountsLoadBatch failed: %v", err)
  89. continue
  90. }
  91. for k, v := range part {
  92. out[k] = v
  93. }
  94. }
  95. return out
  96. }
  97. // GetConcurrencyStats returns real-time concurrency usage aggregated by platform/group/account.
  98. //
  99. // Optional filters:
  100. // - platformFilter: only include accounts in that platform (best-effort reduces DB load)
  101. // - groupIDFilter: only include accounts that belong to that group
  102. func (s *OpsService) GetConcurrencyStats(
  103. ctx context.Context,
  104. platformFilter string,
  105. groupIDFilter *int64,
  106. ) (map[string]*PlatformConcurrencyInfo, map[int64]*GroupConcurrencyInfo, map[int64]*AccountConcurrencyInfo, *time.Time, error) {
  107. if err := s.RequireMonitoringEnabled(ctx); err != nil {
  108. return nil, nil, nil, nil, err
  109. }
  110. accounts, err := s.listAllAccountsForOps(ctx, platformFilter, groupIDFilter)
  111. if err != nil {
  112. return nil, nil, nil, nil, err
  113. }
  114. collectedAt := time.Now()
  115. loadMap := s.getAccountsLoadMapBestEffort(ctx, accounts)
  116. platform := make(map[string]*PlatformConcurrencyInfo)
  117. group := make(map[int64]*GroupConcurrencyInfo)
  118. account := make(map[int64]*AccountConcurrencyInfo)
  119. for _, acc := range accounts {
  120. if acc.ID <= 0 {
  121. continue
  122. }
  123. var matchedGroup *Group
  124. if groupIDFilter != nil && *groupIDFilter > 0 {
  125. for _, grp := range acc.Groups {
  126. if grp == nil || grp.ID <= 0 {
  127. continue
  128. }
  129. if grp.ID == *groupIDFilter {
  130. matchedGroup = grp
  131. break
  132. }
  133. }
  134. // Group filter provided: skip accounts not in that group.
  135. if matchedGroup == nil {
  136. continue
  137. }
  138. }
  139. load := loadMap[acc.ID]
  140. currentInUse := int64(0)
  141. waiting := int64(0)
  142. if load != nil {
  143. currentInUse = int64(load.CurrentConcurrency)
  144. waiting = int64(load.WaitingCount)
  145. }
  146. // Account-level view picks one display group (the first group).
  147. displayGroupID := int64(0)
  148. displayGroupName := ""
  149. if matchedGroup != nil {
  150. displayGroupID = matchedGroup.ID
  151. displayGroupName = matchedGroup.Name
  152. } else if len(acc.Groups) > 0 && acc.Groups[0] != nil {
  153. displayGroupID = acc.Groups[0].ID
  154. displayGroupName = acc.Groups[0].Name
  155. }
  156. if _, ok := account[acc.ID]; !ok {
  157. info := &AccountConcurrencyInfo{
  158. AccountID: acc.ID,
  159. AccountName: acc.Name,
  160. Platform: acc.Platform,
  161. GroupID: displayGroupID,
  162. GroupName: displayGroupName,
  163. CurrentInUse: currentInUse,
  164. MaxCapacity: int64(acc.Concurrency),
  165. WaitingInQueue: waiting,
  166. }
  167. if info.MaxCapacity > 0 {
  168. info.LoadPercentage = float64(info.CurrentInUse) / float64(info.MaxCapacity) * 100
  169. }
  170. account[acc.ID] = info
  171. }
  172. // Platform aggregation.
  173. if acc.Platform != "" {
  174. if _, ok := platform[acc.Platform]; !ok {
  175. platform[acc.Platform] = &PlatformConcurrencyInfo{
  176. Platform: acc.Platform,
  177. }
  178. }
  179. p := platform[acc.Platform]
  180. p.MaxCapacity += int64(acc.Concurrency)
  181. p.CurrentInUse += currentInUse
  182. p.WaitingInQueue += waiting
  183. }
  184. // Group aggregation (one account may contribute to multiple groups).
  185. if matchedGroup != nil {
  186. grp := matchedGroup
  187. if _, ok := group[grp.ID]; !ok {
  188. group[grp.ID] = &GroupConcurrencyInfo{
  189. GroupID: grp.ID,
  190. GroupName: grp.Name,
  191. Platform: grp.Platform,
  192. }
  193. }
  194. g := group[grp.ID]
  195. if g.GroupName == "" && grp.Name != "" {
  196. g.GroupName = grp.Name
  197. }
  198. if g.Platform != "" && grp.Platform != "" && g.Platform != grp.Platform {
  199. // Groups are expected to be platform-scoped. If mismatch is observed, avoid misleading labels.
  200. g.Platform = ""
  201. }
  202. g.MaxCapacity += int64(acc.Concurrency)
  203. g.CurrentInUse += currentInUse
  204. g.WaitingInQueue += waiting
  205. } else {
  206. for _, grp := range acc.Groups {
  207. if grp == nil || grp.ID <= 0 {
  208. continue
  209. }
  210. if _, ok := group[grp.ID]; !ok {
  211. group[grp.ID] = &GroupConcurrencyInfo{
  212. GroupID: grp.ID,
  213. GroupName: grp.Name,
  214. Platform: grp.Platform,
  215. }
  216. }
  217. g := group[grp.ID]
  218. if g.GroupName == "" && grp.Name != "" {
  219. g.GroupName = grp.Name
  220. }
  221. if g.Platform != "" && grp.Platform != "" && g.Platform != grp.Platform {
  222. // Groups are expected to be platform-scoped. If mismatch is observed, avoid misleading labels.
  223. g.Platform = ""
  224. }
  225. g.MaxCapacity += int64(acc.Concurrency)
  226. g.CurrentInUse += currentInUse
  227. g.WaitingInQueue += waiting
  228. }
  229. }
  230. }
  231. for _, info := range platform {
  232. if info.MaxCapacity > 0 {
  233. info.LoadPercentage = float64(info.CurrentInUse) / float64(info.MaxCapacity) * 100
  234. }
  235. }
  236. for _, info := range group {
  237. if info.MaxCapacity > 0 {
  238. info.LoadPercentage = float64(info.CurrentInUse) / float64(info.MaxCapacity) * 100
  239. }
  240. }
  241. return platform, group, account, &collectedAt, nil
  242. }
  243. // listAllActiveUsersForOps returns all active users with their concurrency settings.
  244. func (s *OpsService) listAllActiveUsersForOps(ctx context.Context) ([]User, error) {
  245. if s == nil || s.userRepo == nil {
  246. return []User{}, nil
  247. }
  248. out := make([]User, 0, 128)
  249. page := 1
  250. for {
  251. users, pageInfo, err := s.userRepo.ListWithFilters(ctx, pagination.PaginationParams{
  252. Page: page,
  253. PageSize: opsAccountsPageSize,
  254. }, UserListFilters{
  255. Status: StatusActive,
  256. })
  257. if err != nil {
  258. return nil, err
  259. }
  260. if len(users) == 0 {
  261. break
  262. }
  263. out = append(out, users...)
  264. if pageInfo != nil && int64(len(out)) >= pageInfo.Total {
  265. break
  266. }
  267. if len(users) < opsAccountsPageSize {
  268. break
  269. }
  270. page++
  271. if page > 10_000 {
  272. log.Printf("[Ops] listAllActiveUsersForOps: aborting after too many pages")
  273. break
  274. }
  275. }
  276. return out, nil
  277. }
  278. // getUsersLoadMapBestEffort returns user load info for the given users.
  279. func (s *OpsService) getUsersLoadMapBestEffort(ctx context.Context, users []User) map[int64]*UserLoadInfo {
  280. if s == nil || s.concurrencyService == nil {
  281. return map[int64]*UserLoadInfo{}
  282. }
  283. if len(users) == 0 {
  284. return map[int64]*UserLoadInfo{}
  285. }
  286. // De-duplicate IDs (and keep the max concurrency to avoid under-reporting).
  287. unique := make(map[int64]int, len(users))
  288. for _, u := range users {
  289. if u.ID <= 0 {
  290. continue
  291. }
  292. if prev, ok := unique[u.ID]; !ok || u.Concurrency > prev {
  293. unique[u.ID] = u.Concurrency
  294. }
  295. }
  296. batch := make([]UserWithConcurrency, 0, len(unique))
  297. for id, maxConc := range unique {
  298. batch = append(batch, UserWithConcurrency{
  299. ID: id,
  300. MaxConcurrency: maxConc,
  301. })
  302. }
  303. out := make(map[int64]*UserLoadInfo, len(batch))
  304. for i := 0; i < len(batch); i += opsConcurrencyBatchChunkSize {
  305. end := i + opsConcurrencyBatchChunkSize
  306. if end > len(batch) {
  307. end = len(batch)
  308. }
  309. part, err := s.concurrencyService.GetUsersLoadBatch(ctx, batch[i:end])
  310. if err != nil {
  311. // Best-effort: return zeros rather than failing the ops UI.
  312. log.Printf("[Ops] GetUsersLoadBatch failed: %v", err)
  313. continue
  314. }
  315. for k, v := range part {
  316. out[k] = v
  317. }
  318. }
  319. return out
  320. }
  321. // GetUserConcurrencyStats returns real-time concurrency usage for all active users.
  322. func (s *OpsService) GetUserConcurrencyStats(ctx context.Context) (map[int64]*UserConcurrencyInfo, *time.Time, error) {
  323. if err := s.RequireMonitoringEnabled(ctx); err != nil {
  324. return nil, nil, err
  325. }
  326. users, err := s.listAllActiveUsersForOps(ctx)
  327. if err != nil {
  328. return nil, nil, err
  329. }
  330. collectedAt := time.Now()
  331. loadMap := s.getUsersLoadMapBestEffort(ctx, users)
  332. result := make(map[int64]*UserConcurrencyInfo)
  333. for _, u := range users {
  334. if u.ID <= 0 {
  335. continue
  336. }
  337. load := loadMap[u.ID]
  338. currentInUse := int64(0)
  339. waiting := int64(0)
  340. if load != nil {
  341. currentInUse = int64(load.CurrentConcurrency)
  342. waiting = int64(load.WaitingCount)
  343. }
  344. // Skip users with no concurrency activity
  345. if currentInUse == 0 && waiting == 0 {
  346. continue
  347. }
  348. info := &UserConcurrencyInfo{
  349. UserID: u.ID,
  350. UserEmail: u.Email,
  351. Username: u.Username,
  352. CurrentInUse: currentInUse,
  353. MaxCapacity: int64(u.Concurrency),
  354. WaitingInQueue: waiting,
  355. }
  356. if info.MaxCapacity > 0 {
  357. info.LoadPercentage = float64(info.CurrentInUse) / float64(info.MaxCapacity) * 100
  358. }
  359. result[u.ID] = info
  360. }
  361. return result, &collectedAt, nil
  362. }