scheduler_outbox_repo.go 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. package repository
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "database/sql"
  6. "encoding/hex"
  7. "encoding/json"
  8. "fmt"
  9. "strconv"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. )
  13. type schedulerOutboxRepository struct {
  14. db *sql.DB
  15. }
  16. type schedulerOutboxCleanupLease struct {
  17. conn *sql.Conn
  18. }
  19. const schedulerOutboxDefaultCleanSize = 5000
  20. func NewSchedulerOutboxRepository(db *sql.DB) service.SchedulerOutboxRepository {
  21. return &schedulerOutboxRepository{db: db}
  22. }
  23. func (r *schedulerOutboxRepository) ListAfterAndReleaseDedup(ctx context.Context, afterID int64, limit int) ([]service.SchedulerOutboxEvent, error) {
  24. if limit <= 0 {
  25. limit = 100
  26. }
  27. rows, err := r.db.QueryContext(ctx, `
  28. WITH selected AS MATERIALIZED (
  29. SELECT id, event_type, account_id, group_id, payload, created_at
  30. FROM scheduler_outbox
  31. WHERE id > $1
  32. ORDER BY id ASC
  33. LIMIT $2
  34. FOR UPDATE
  35. ), released AS (
  36. UPDATE scheduler_outbox AS o
  37. SET dedup_key = NULL
  38. FROM selected AS s
  39. WHERE o.id = s.id
  40. AND o.dedup_key IS NOT NULL
  41. RETURNING o.id
  42. )
  43. SELECT s.id, s.event_type, s.account_id, s.group_id, s.payload, s.created_at
  44. FROM selected AS s
  45. CROSS JOIN (SELECT COUNT(*) FROM released) AS release_barrier
  46. ORDER BY s.id ASC
  47. `, afterID, limit)
  48. if err != nil {
  49. return nil, err
  50. }
  51. defer func() {
  52. _ = rows.Close()
  53. }()
  54. events := make([]service.SchedulerOutboxEvent, 0, limit)
  55. for rows.Next() {
  56. var (
  57. payloadRaw []byte
  58. accountID sql.NullInt64
  59. groupID sql.NullInt64
  60. event service.SchedulerOutboxEvent
  61. )
  62. if err := rows.Scan(&event.ID, &event.EventType, &accountID, &groupID, &payloadRaw, &event.CreatedAt); err != nil {
  63. return nil, err
  64. }
  65. if accountID.Valid {
  66. v := accountID.Int64
  67. event.AccountID = &v
  68. }
  69. if groupID.Valid {
  70. v := groupID.Int64
  71. event.GroupID = &v
  72. }
  73. if len(payloadRaw) > 0 {
  74. var payload map[string]any
  75. if err := json.Unmarshal(payloadRaw, &payload); err != nil {
  76. return nil, err
  77. }
  78. event.Payload = payload
  79. }
  80. events = append(events, event)
  81. }
  82. if err := rows.Err(); err != nil {
  83. return nil, err
  84. }
  85. return events, nil
  86. }
  87. func (r *schedulerOutboxRepository) FirstCreatedAtAfter(ctx context.Context, afterID int64) (time.Time, bool, error) {
  88. var createdAt time.Time
  89. err := r.db.QueryRowContext(ctx, `
  90. SELECT created_at
  91. FROM scheduler_outbox
  92. WHERE id > $1
  93. ORDER BY id ASC
  94. LIMIT 1
  95. `, afterID).Scan(&createdAt)
  96. if err == sql.ErrNoRows {
  97. return time.Time{}, false, nil
  98. }
  99. if err != nil {
  100. return time.Time{}, false, err
  101. }
  102. return createdAt, true, nil
  103. }
  104. func (r *schedulerOutboxRepository) MaxID(ctx context.Context) (int64, error) {
  105. var maxID int64
  106. if err := r.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(id), 0) FROM scheduler_outbox").Scan(&maxID); err != nil {
  107. return 0, err
  108. }
  109. return maxID, nil
  110. }
  111. func (r *schedulerOutboxRepository) DeleteConsumedUpTo(ctx context.Context, watermark int64, limit int) (int64, error) {
  112. if watermark <= 0 {
  113. return 0, nil
  114. }
  115. if limit <= 0 {
  116. limit = schedulerOutboxDefaultCleanSize
  117. }
  118. // created_at < NOW() - INTERVAL '10 seconds' 防御 PG 序列号在事务内提前分配但
  119. // 提交延迟的竞争:若某 Tx 在 watermark 推进前持有 id=N(未提交),watermark
  120. // 跨过 N 后该 Tx 才提交,此时 row N 已经"低于 watermark"但从未被 poll;10s
  121. // 宽限期让此类慢事务有机会提交后被消费,再被 cleanup 删除。
  122. result, err := r.db.ExecContext(ctx, `
  123. WITH doomed AS (
  124. SELECT id
  125. FROM scheduler_outbox
  126. WHERE id <= $1
  127. AND created_at < NOW() - INTERVAL '10 seconds'
  128. ORDER BY id ASC
  129. LIMIT $2
  130. )
  131. DELETE FROM scheduler_outbox o
  132. USING doomed d
  133. WHERE o.id = d.id
  134. `, watermark, limit)
  135. if err != nil {
  136. return 0, err
  137. }
  138. return result.RowsAffected()
  139. }
  140. func (r *schedulerOutboxRepository) TryAcquireCleanupLock(ctx context.Context) (service.SchedulerOutboxCleanupLease, bool, error) {
  141. conn, err := r.db.Conn(ctx)
  142. if err != nil {
  143. return nil, false, err
  144. }
  145. var acquired bool
  146. if err := conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock(hashtext('scheduler_outbox_cleanup'))").Scan(&acquired); err != nil {
  147. _ = conn.Close()
  148. return nil, false, err
  149. }
  150. if !acquired {
  151. _ = conn.Close()
  152. return nil, false, nil
  153. }
  154. return &schedulerOutboxCleanupLease{conn: conn}, true, nil
  155. }
  156. func (l *schedulerOutboxCleanupLease) Release() {
  157. if l == nil || l.conn == nil {
  158. return
  159. }
  160. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  161. defer cancel()
  162. _, _ = l.conn.ExecContext(ctx, "SELECT pg_advisory_unlock(hashtext('scheduler_outbox_cleanup'))")
  163. _ = l.conn.Close()
  164. l.conn = nil
  165. }
  166. func enqueueSchedulerOutbox(ctx context.Context, exec sqlExecutor, eventType string, accountID *int64, groupID *int64, payload any) error {
  167. if exec == nil {
  168. return nil
  169. }
  170. var payloadArg any
  171. var payloadJSON []byte
  172. if payload != nil {
  173. encoded, err := json.Marshal(payload)
  174. if err != nil {
  175. return err
  176. }
  177. payloadArg = encoded
  178. payloadJSON = encoded
  179. }
  180. query := `
  181. INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)
  182. VALUES ($1, $2, $3, $4)
  183. `
  184. args := []any{eventType, accountID, groupID, payloadArg}
  185. if schedulerOutboxEventSupportsDedup(eventType) {
  186. dedupKey := schedulerOutboxDedupKey(eventType, accountID, groupID, payloadJSON)
  187. query = `
  188. INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload, dedup_key)
  189. VALUES ($1, $2, $3, $4, $5)
  190. ON CONFLICT (dedup_key) WHERE dedup_key IS NOT NULL DO NOTHING
  191. `
  192. args = append(args, dedupKey)
  193. }
  194. _, err := exec.ExecContext(ctx, query, args...)
  195. return err
  196. }
  197. func schedulerOutboxDedupKey(eventType string, accountID *int64, groupID *int64, payloadJSON []byte) string {
  198. h := sha256.New()
  199. _, _ = h.Write([]byte(eventType))
  200. _, _ = h.Write([]byte{0})
  201. if accountID != nil {
  202. _, _ = h.Write([]byte(strconv.FormatInt(*accountID, 10)))
  203. }
  204. _, _ = h.Write([]byte{0})
  205. if groupID != nil {
  206. _, _ = h.Write([]byte(strconv.FormatInt(*groupID, 10)))
  207. }
  208. _, _ = h.Write([]byte{0})
  209. _, _ = h.Write(payloadJSON)
  210. return fmt.Sprintf("scheduler_outbox:%s", hex.EncodeToString(h.Sum(nil)))
  211. }
  212. func schedulerOutboxEventSupportsDedup(eventType string) bool {
  213. switch eventType {
  214. case service.SchedulerOutboxEventAccountChanged,
  215. service.SchedulerOutboxEventGroupChanged,
  216. service.SchedulerOutboxEventFullRebuild:
  217. return true
  218. default:
  219. return false
  220. }
  221. }