fix: cleanup consumed scheduler outbox rows

This commit is contained in:
jjaw authored and shaw committed 2026-06-16 11:56:40 +08:00
1 parent 4254dcb352
commit cb14935e9a
5 files changed
+464 -1

No files matched your search

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
)
@@ -16,6 +17,12 @@ 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}
}
@@ -94,6 +101,60 @@ func (r *schedulerOutboxRepository) MaxID(ctx context.Context) (int64, error) {
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
}
result, err := r.db.ExecContext(ctx, `
WITH doomed AS (
SELECT id
FROM scheduler_outbox
WHERE id <= $1
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
@@ -0,0 +1,91 @@
package repository
import (
"context"
"regexp"
"testing"
sqlmock "github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/require"
)
func TestSchedulerOutboxRepositoryDeleteConsumedUpToUsesBoundedCTE(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer func() { _ = db.Close() }()
repo := &schedulerOutboxRepository{db: db}
const expectedSQL = `
WITH doomed AS (
SELECT id
FROM scheduler_outbox
WHERE id <= $1
ORDER BY id ASC
LIMIT $2
)
DELETE FROM scheduler_outbox o
USING doomed d
WHERE o.id = d.id
`
mock.ExpectExec(regexp.QuoteMeta(expectedSQL)).
WithArgs(int64(42), 5000).
WillReturnResult(sqlmock.NewResult(0, 17))
deleted, err := repo.DeleteConsumedUpTo(context.Background(), 42, 5000)
require.NoError(t, err)
require.EqualValues(t, 17, deleted)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestSchedulerOutboxRepositoryDeleteConsumedUpToSkipsNonPositiveWatermark(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer func() { _ = db.Close() }()
repo := &schedulerOutboxRepository{db: db}
deleted, err := repo.DeleteConsumedUpTo(context.Background(), 0, 5000)
require.NoError(t, err)
require.EqualValues(t, 0, deleted)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestSchedulerOutboxRepositoryTryAcquireCleanupLock(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer func() { _ = db.Close() }()
repo := &schedulerOutboxRepository{db: db}
mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock(hashtext('scheduler_outbox_cleanup'))")).
WillReturnRows(sqlmock.NewRows([]string{"pg_try_advisory_lock"}).AddRow(true))
mock.ExpectExec(regexp.QuoteMeta("SELECT pg_advisory_unlock(hashtext('scheduler_outbox_cleanup'))")).
WillReturnResult(sqlmock.NewResult(0, 1))
lease, acquired, err := repo.TryAcquireCleanupLock(context.Background())
require.NoError(t, err)
require.True(t, acquired)
require.NotNil(t, lease)
lease.Release()
require.NoError(t, mock.ExpectationsWereMet())
}
func TestSchedulerOutboxRepositoryTryAcquireCleanupLockUnavailable(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer func() { _ = db.Close() }()
repo := &schedulerOutboxRepository{db: db}
mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock(hashtext('scheduler_outbox_cleanup'))")).
WillReturnRows(sqlmock.NewRows([]string{"pg_try_advisory_lock"}).AddRow(false))
lease, acquired, err := repo.TryAcquireCleanupLock(context.Background())
require.NoError(t, err)
require.False(t, acquired)
require.Nil(t, lease)
require.NoError(t, mock.ExpectationsWereMet())
}