| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- package repository
- import (
- "context"
- "crypto/sha256"
- "database/sql"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "strconv"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/service"
- )
- type schedulerOutboxRepository struct {
- db *sql.DB
- }
- type schedulerOutboxCleanupLease struct {
- conn *sql.Conn
- }
- const schedulerOutboxDefaultCleanSize = 5000
- func NewSchedulerOutboxRepository(db *sql.DB) service.SchedulerOutboxRepository {
- return &schedulerOutboxRepository{db: db}
- }
- func (r *schedulerOutboxRepository) ListAfterAndReleaseDedup(ctx context.Context, afterID int64, limit int) ([]service.SchedulerOutboxEvent, error) {
- if limit <= 0 {
- limit = 100
- }
- rows, err := r.db.QueryContext(ctx, `
- WITH selected AS MATERIALIZED (
- SELECT id, event_type, account_id, group_id, payload, created_at
- FROM scheduler_outbox
- WHERE id > $1
- ORDER BY id ASC
- LIMIT $2
- FOR UPDATE
- ), released AS (
- UPDATE scheduler_outbox AS o
- SET dedup_key = NULL
- FROM selected AS s
- WHERE o.id = s.id
- AND o.dedup_key IS NOT NULL
- RETURNING o.id
- )
- SELECT s.id, s.event_type, s.account_id, s.group_id, s.payload, s.created_at
- FROM selected AS s
- CROSS JOIN (SELECT COUNT(*) FROM released) AS release_barrier
- ORDER BY s.id ASC
- `, afterID, limit)
- if err != nil {
- return nil, err
- }
- defer func() {
- _ = rows.Close()
- }()
- events := make([]service.SchedulerOutboxEvent, 0, limit)
- for rows.Next() {
- var (
- payloadRaw []byte
- accountID sql.NullInt64
- groupID sql.NullInt64
- event service.SchedulerOutboxEvent
- )
- if err := rows.Scan(&event.ID, &event.EventType, &accountID, &groupID, &payloadRaw, &event.CreatedAt); err != nil {
- return nil, err
- }
- if accountID.Valid {
- v := accountID.Int64
- event.AccountID = &v
- }
- if groupID.Valid {
- v := groupID.Int64
- event.GroupID = &v
- }
- if len(payloadRaw) > 0 {
- var payload map[string]any
- if err := json.Unmarshal(payloadRaw, &payload); err != nil {
- return nil, err
- }
- event.Payload = payload
- }
- events = append(events, event)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return events, nil
- }
- func (r *schedulerOutboxRepository) FirstCreatedAtAfter(ctx context.Context, afterID int64) (time.Time, bool, error) {
- var createdAt time.Time
- err := r.db.QueryRowContext(ctx, `
- SELECT created_at
- FROM scheduler_outbox
- WHERE id > $1
- ORDER BY id ASC
- LIMIT 1
- `, afterID).Scan(&createdAt)
- if err == sql.ErrNoRows {
- return time.Time{}, false, nil
- }
- if err != nil {
- return time.Time{}, false, err
- }
- return createdAt, true, nil
- }
- func (r *schedulerOutboxRepository) MaxID(ctx context.Context) (int64, error) {
- var maxID int64
- if err := r.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(id), 0) FROM scheduler_outbox").Scan(&maxID); err != nil {
- return 0, err
- }
- return maxID, nil
- }
- func (r *schedulerOutboxRepository) DeleteConsumedUpTo(ctx context.Context, watermark int64, limit int) (int64, error) {
- if watermark <= 0 {
- return 0, nil
- }
- if limit <= 0 {
- limit = schedulerOutboxDefaultCleanSize
- }
- // created_at < NOW() - INTERVAL '10 seconds' 防御 PG 序列号在事务内提前分配但
- // 提交延迟的竞争:若某 Tx 在 watermark 推进前持有 id=N(未提交),watermark
- // 跨过 N 后该 Tx 才提交,此时 row N 已经"低于 watermark"但从未被 poll;10s
- // 宽限期让此类慢事务有机会提交后被消费,再被 cleanup 删除。
- result, err := r.db.ExecContext(ctx, `
- WITH doomed AS (
- SELECT id
- FROM scheduler_outbox
- WHERE id <= $1
- AND created_at < NOW() - INTERVAL '10 seconds'
- ORDER BY id ASC
- LIMIT $2
- )
- DELETE FROM scheduler_outbox o
- USING doomed d
- WHERE o.id = d.id
- `, watermark, limit)
- if err != nil {
- return 0, err
- }
- return result.RowsAffected()
- }
- func (r *schedulerOutboxRepository) TryAcquireCleanupLock(ctx context.Context) (service.SchedulerOutboxCleanupLease, bool, error) {
- conn, err := r.db.Conn(ctx)
- if err != nil {
- return nil, false, err
- }
- var acquired bool
- if err := conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock(hashtext('scheduler_outbox_cleanup'))").Scan(&acquired); err != nil {
- _ = conn.Close()
- return nil, false, err
- }
- if !acquired {
- _ = conn.Close()
- return nil, false, nil
- }
- return &schedulerOutboxCleanupLease{conn: conn}, true, nil
- }
- func (l *schedulerOutboxCleanupLease) Release() {
- if l == nil || l.conn == nil {
- return
- }
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- _, _ = l.conn.ExecContext(ctx, "SELECT pg_advisory_unlock(hashtext('scheduler_outbox_cleanup'))")
- _ = l.conn.Close()
- l.conn = nil
- }
- func enqueueSchedulerOutbox(ctx context.Context, exec sqlExecutor, eventType string, accountID *int64, groupID *int64, payload any) error {
- if exec == nil {
- return nil
- }
- var payloadArg any
- var payloadJSON []byte
- if payload != nil {
- encoded, err := json.Marshal(payload)
- if err != nil {
- return err
- }
- payloadArg = encoded
- payloadJSON = encoded
- }
- query := `
- INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)
- VALUES ($1, $2, $3, $4)
- `
- args := []any{eventType, accountID, groupID, payloadArg}
- if schedulerOutboxEventSupportsDedup(eventType) {
- dedupKey := schedulerOutboxDedupKey(eventType, accountID, groupID, payloadJSON)
- query = `
- INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload, dedup_key)
- VALUES ($1, $2, $3, $4, $5)
- ON CONFLICT (dedup_key) WHERE dedup_key IS NOT NULL DO NOTHING
- `
- args = append(args, dedupKey)
- }
- _, err := exec.ExecContext(ctx, query, args...)
- return err
- }
- func schedulerOutboxDedupKey(eventType string, accountID *int64, groupID *int64, payloadJSON []byte) string {
- h := sha256.New()
- _, _ = h.Write([]byte(eventType))
- _, _ = h.Write([]byte{0})
- if accountID != nil {
- _, _ = h.Write([]byte(strconv.FormatInt(*accountID, 10)))
- }
- _, _ = h.Write([]byte{0})
- if groupID != nil {
- _, _ = h.Write([]byte(strconv.FormatInt(*groupID, 10)))
- }
- _, _ = h.Write([]byte{0})
- _, _ = h.Write(payloadJSON)
- return fmt.Sprintf("scheduler_outbox:%s", hex.EncodeToString(h.Sum(nil)))
- }
- func schedulerOutboxEventSupportsDedup(eventType string) bool {
- switch eventType {
- case service.SchedulerOutboxEventAccountChanged,
- service.SchedulerOutboxEventGroupChanged,
- service.SchedulerOutboxEventFullRebuild:
- return true
- default:
- return false
- }
- }
|