mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-08 08:55:22 +00:00
fix: cleanup consumed scheduler outbox rows
This commit is contained in:
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())
|
||||
}
|
||||
Reference in new issue
Block a user