idempotency_repo.go 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "time"
  7. dbent "github.com/Wei-Shaw/sub2api/ent"
  8. "github.com/Wei-Shaw/sub2api/internal/service"
  9. )
  10. type idempotencyRepository struct {
  11. sql sqlExecutor
  12. }
  13. func NewIdempotencyRepository(_ *dbent.Client, sqlDB *sql.DB) service.IdempotencyRepository {
  14. return &idempotencyRepository{sql: sqlDB}
  15. }
  16. func (r *idempotencyRepository) CreateProcessing(ctx context.Context, record *service.IdempotencyRecord) (bool, error) {
  17. if record == nil {
  18. return false, nil
  19. }
  20. query := `
  21. INSERT INTO idempotency_records (
  22. scope, idempotency_key_hash, request_fingerprint, status, locked_until, expires_at
  23. ) VALUES ($1, $2, $3, $4, $5, $6)
  24. ON CONFLICT (scope, idempotency_key_hash) DO NOTHING
  25. RETURNING id, created_at, updated_at
  26. `
  27. var createdAt time.Time
  28. var updatedAt time.Time
  29. err := scanSingleRow(ctx, r.sql, query, []any{
  30. record.Scope,
  31. record.IdempotencyKeyHash,
  32. record.RequestFingerprint,
  33. record.Status,
  34. record.LockedUntil,
  35. record.ExpiresAt,
  36. }, &record.ID, &createdAt, &updatedAt)
  37. if errors.Is(err, sql.ErrNoRows) {
  38. return false, nil
  39. }
  40. if err != nil {
  41. return false, err
  42. }
  43. record.CreatedAt = createdAt
  44. record.UpdatedAt = updatedAt
  45. return true, nil
  46. }
  47. func (r *idempotencyRepository) GetByScopeAndKeyHash(ctx context.Context, scope, keyHash string) (*service.IdempotencyRecord, error) {
  48. query := `
  49. SELECT
  50. id, scope, idempotency_key_hash, request_fingerprint, status, response_status,
  51. response_body, error_reason, locked_until, expires_at, created_at, updated_at
  52. FROM idempotency_records
  53. WHERE scope = $1 AND idempotency_key_hash = $2
  54. `
  55. record := &service.IdempotencyRecord{}
  56. var responseStatus sql.NullInt64
  57. var responseBody sql.NullString
  58. var errorReason sql.NullString
  59. var lockedUntil sql.NullTime
  60. err := scanSingleRow(ctx, r.sql, query, []any{scope, keyHash},
  61. &record.ID,
  62. &record.Scope,
  63. &record.IdempotencyKeyHash,
  64. &record.RequestFingerprint,
  65. &record.Status,
  66. &responseStatus,
  67. &responseBody,
  68. &errorReason,
  69. &lockedUntil,
  70. &record.ExpiresAt,
  71. &record.CreatedAt,
  72. &record.UpdatedAt,
  73. )
  74. if errors.Is(err, sql.ErrNoRows) {
  75. return nil, nil
  76. }
  77. if err != nil {
  78. return nil, err
  79. }
  80. if responseStatus.Valid {
  81. v := int(responseStatus.Int64)
  82. record.ResponseStatus = &v
  83. }
  84. if responseBody.Valid {
  85. v := responseBody.String
  86. record.ResponseBody = &v
  87. }
  88. if errorReason.Valid {
  89. v := errorReason.String
  90. record.ErrorReason = &v
  91. }
  92. if lockedUntil.Valid {
  93. v := lockedUntil.Time
  94. record.LockedUntil = &v
  95. }
  96. return record, nil
  97. }
  98. func (r *idempotencyRepository) TryReclaim(
  99. ctx context.Context,
  100. id int64,
  101. fromStatus string,
  102. now, newLockedUntil, newExpiresAt time.Time,
  103. ) (bool, error) {
  104. query := `
  105. UPDATE idempotency_records
  106. SET status = $2,
  107. locked_until = $3,
  108. error_reason = NULL,
  109. updated_at = NOW(),
  110. expires_at = $4
  111. WHERE id = $1
  112. AND status = $5
  113. AND (locked_until IS NULL OR locked_until <= $6)
  114. `
  115. res, err := r.sql.ExecContext(ctx, query,
  116. id,
  117. service.IdempotencyStatusProcessing,
  118. newLockedUntil,
  119. newExpiresAt,
  120. fromStatus,
  121. now,
  122. )
  123. if err != nil {
  124. return false, err
  125. }
  126. affected, err := res.RowsAffected()
  127. if err != nil {
  128. return false, err
  129. }
  130. return affected > 0, nil
  131. }
  132. func (r *idempotencyRepository) ExtendProcessingLock(
  133. ctx context.Context,
  134. id int64,
  135. requestFingerprint string,
  136. newLockedUntil,
  137. newExpiresAt time.Time,
  138. ) (bool, error) {
  139. query := `
  140. UPDATE idempotency_records
  141. SET locked_until = $2,
  142. expires_at = $3,
  143. updated_at = NOW()
  144. WHERE id = $1
  145. AND status = $4
  146. AND request_fingerprint = $5
  147. `
  148. res, err := r.sql.ExecContext(
  149. ctx,
  150. query,
  151. id,
  152. newLockedUntil,
  153. newExpiresAt,
  154. service.IdempotencyStatusProcessing,
  155. requestFingerprint,
  156. )
  157. if err != nil {
  158. return false, err
  159. }
  160. affected, err := res.RowsAffected()
  161. if err != nil {
  162. return false, err
  163. }
  164. return affected > 0, nil
  165. }
  166. func (r *idempotencyRepository) MarkSucceeded(ctx context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
  167. query := `
  168. UPDATE idempotency_records
  169. SET status = $2,
  170. response_status = $3,
  171. response_body = $4,
  172. error_reason = NULL,
  173. locked_until = NULL,
  174. expires_at = $5,
  175. updated_at = NOW()
  176. WHERE id = $1
  177. `
  178. _, err := r.sql.ExecContext(ctx, query,
  179. id,
  180. service.IdempotencyStatusSucceeded,
  181. responseStatus,
  182. responseBody,
  183. expiresAt,
  184. )
  185. return err
  186. }
  187. func (r *idempotencyRepository) MarkFailedRetryable(ctx context.Context, id int64, errorReason string, lockedUntil, expiresAt time.Time) error {
  188. query := `
  189. UPDATE idempotency_records
  190. SET status = $2,
  191. error_reason = $3,
  192. locked_until = $4,
  193. expires_at = $5,
  194. updated_at = NOW()
  195. WHERE id = $1
  196. `
  197. _, err := r.sql.ExecContext(ctx, query,
  198. id,
  199. service.IdempotencyStatusFailedRetryable,
  200. errorReason,
  201. lockedUntil,
  202. expiresAt,
  203. )
  204. return err
  205. }
  206. func (r *idempotencyRepository) DeleteExpired(ctx context.Context, now time.Time, limit int) (int64, error) {
  207. if limit <= 0 {
  208. limit = 500
  209. }
  210. query := `
  211. WITH victims AS (
  212. SELECT id
  213. FROM idempotency_records
  214. WHERE expires_at <= $1
  215. ORDER BY expires_at ASC
  216. LIMIT $2
  217. )
  218. DELETE FROM idempotency_records
  219. WHERE id IN (SELECT id FROM victims)
  220. `
  221. res, err := r.sql.ExecContext(ctx, query, now, limit)
  222. if err != nil {
  223. return 0, err
  224. }
  225. return res.RowsAffected()
  226. }