Files
sub2api/backend/internal/repository/scheduler_outbox_repo.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

239 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"但从未被 poll10s
// 宽限期让此类慢事务有机会提交后被消费,再被 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
}
}