优先回收outbox重试和过期租约
This commit is contained in:
parent
e0cd7c12c9
commit
36b01aebb7
@ -0,0 +1,81 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClaimPendingGameOutboxPrioritizesRecoveryOverPendingAtBatchOne(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
eventID string
|
||||||
|
recoveryStatus string
|
||||||
|
expectEmptyRetry bool
|
||||||
|
}{
|
||||||
|
{name: "due retry", eventID: "game-retry", recoveryStatus: "retryable"},
|
||||||
|
{name: "expired run", eventID: "game-expired", recoveryStatus: "running", expectEmptyRetry: true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
if tt.expectEmptyRetry {
|
||||||
|
mock.ExpectQuery(gameOutboxRetryClaimPattern()).
|
||||||
|
WithArgs("lalu", sqlmock.AnyArg(), 1).
|
||||||
|
WillReturnRows(gameOutboxClaimRows())
|
||||||
|
}
|
||||||
|
claimPattern := gameOutboxRetryClaimPattern()
|
||||||
|
if tt.recoveryStatus == "running" {
|
||||||
|
claimPattern = gameOutboxExpiredClaimPattern()
|
||||||
|
}
|
||||||
|
mock.ExpectQuery(claimPattern).
|
||||||
|
WithArgs("lalu", sqlmock.AnyArg(), 1).
|
||||||
|
WillReturnRows(gameOutboxClaimRows().AddRow(
|
||||||
|
"lalu", tt.eventID, "GameOrderSettled", "order-1", int64(1001), "self", "dice", "debit",
|
||||||
|
int64(10), `{}`, int64(200),
|
||||||
|
))
|
||||||
|
mock.ExpectExec(`(?s)UPDATE game_outbox\s+SET status = 'running', worker_id = \?, lock_until_ms = \?, updated_at_ms = \?\s+WHERE app_code = \? AND event_id = \?`).
|
||||||
|
WithArgs("worker-1", sqlmock.AnyArg(), sqlmock.AnyArg(), "lalu", tt.eventID).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
records, err := (&Repository{db: db}).ClaimPendingGameOutbox(
|
||||||
|
appcode.WithContext(context.Background(), "lalu"), "worker-1", 1,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("claim game recovery row: %v", err)
|
||||||
|
}
|
||||||
|
if len(records) != 1 || records[0].EventID != tt.eventID {
|
||||||
|
t.Fatalf("batch=1 must claim %s before an older pending row: %+v", tt.recoveryStatus, records)
|
||||||
|
}
|
||||||
|
// No pending query is registered: issuing one after the recovery row filled
|
||||||
|
// the batch makes sqlmock fail, protecting the starvation boundary directly.
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("game claim priority mismatch: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameOutboxClaimRows() *sqlmock.Rows {
|
||||||
|
return sqlmock.NewRows([]string{
|
||||||
|
"app_code", "event_id", "event_type", "order_id", "user_id", "platform_code", "game_id", "op_type",
|
||||||
|
"coin_amount", "payload_json", "created_at_ms",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameOutboxRetryClaimPattern() string {
|
||||||
|
return `(?s)FROM game_outbox FORCE INDEX \(idx_game_outbox_status_created\).*WHERE app_code = \? AND status = 'retryable' AND next_retry_at_ms <= \? AND lock_until_ms = 0.*FOR UPDATE SKIP LOCKED`
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameOutboxExpiredClaimPattern() string {
|
||||||
|
return `(?s)FROM game_outbox FORCE INDEX \(idx_game_outbox_lock\).*WHERE app_code = \? AND status = 'running' AND lock_until_ms <= \?.*FOR UPDATE SKIP LOCKED`
|
||||||
|
}
|
||||||
@ -1196,21 +1196,14 @@ func (r *Repository) ClaimPendingGameOutbox(ctx context.Context, workerID string
|
|||||||
lockUntilMS := nowMS + (30 * time.Second).Milliseconds()
|
lockUntilMS := nowMS + (30 * time.Second).Milliseconds()
|
||||||
appCode := appcode.FromContext(ctx)
|
appCode := appcode.FromContext(ctx)
|
||||||
records := make([]GameOutboxRecord, 0, batchSize)
|
records := make([]GameOutboxRecord, 0, batchSize)
|
||||||
|
// Recovery work must claim before fresh pending facts. A continuously growing
|
||||||
|
// pending backlog can otherwise fill every batch and permanently strand both
|
||||||
|
// due retries and running rows whose worker lease expired. Keep the states in
|
||||||
|
// separate SKIP LOCKED queries so each branch retains its status-leading index.
|
||||||
claimBranches := []struct {
|
claimBranches := []struct {
|
||||||
query string
|
query string
|
||||||
args []any
|
args []any
|
||||||
}{
|
}{
|
||||||
{
|
|
||||||
query: `
|
|
||||||
SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
|
|
||||||
coin_amount, CAST(payload_json AS CHAR), created_at_ms
|
|
||||||
FROM game_outbox FORCE INDEX (idx_game_outbox_status_created)
|
|
||||||
WHERE app_code = ? AND status = 'pending' AND next_retry_at_ms <= ? AND lock_until_ms = 0
|
|
||||||
ORDER BY created_at_ms ASC, event_id ASC
|
|
||||||
LIMIT ? FOR UPDATE SKIP LOCKED
|
|
||||||
`,
|
|
||||||
args: []any{appCode, nowMS},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
query: `
|
query: `
|
||||||
SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
|
SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
|
||||||
@ -1230,6 +1223,17 @@ func (r *Repository) ClaimPendingGameOutbox(ctx context.Context, workerID string
|
|||||||
WHERE app_code = ? AND status = 'running' AND lock_until_ms <= ?
|
WHERE app_code = ? AND status = 'running' AND lock_until_ms <= ?
|
||||||
ORDER BY created_at_ms ASC, event_id ASC
|
ORDER BY created_at_ms ASC, event_id ASC
|
||||||
LIMIT ? FOR UPDATE SKIP LOCKED
|
LIMIT ? FOR UPDATE SKIP LOCKED
|
||||||
|
`,
|
||||||
|
args: []any{appCode, nowMS},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: `
|
||||||
|
SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
|
||||||
|
coin_amount, CAST(payload_json AS CHAR), created_at_ms
|
||||||
|
FROM game_outbox FORCE INDEX (idx_game_outbox_status_created)
|
||||||
|
WHERE app_code = ? AND status = 'pending' AND next_retry_at_ms <= ? AND lock_until_ms = 0
|
||||||
|
ORDER BY created_at_ms ASC, event_id ASC
|
||||||
|
LIMIT ? FOR UPDATE SKIP LOCKED
|
||||||
`,
|
`,
|
||||||
args: []any{appCode, nowMS},
|
args: []any{appCode, nowMS},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -69,20 +69,14 @@ func (r *Repository) ClaimPendingUserOutbox(ctx context.Context, workerID string
|
|||||||
lockUntilMS := nowMS + (30 * time.Second).Milliseconds()
|
lockUntilMS := nowMS + (30 * time.Second).Milliseconds()
|
||||||
appCode := appcode.FromContext(ctx)
|
appCode := appcode.FromContext(ctx)
|
||||||
records := make([]UserOutboxRecord, 0, batchSize)
|
records := make([]UserOutboxRecord, 0, batchSize)
|
||||||
|
// Recovery work must claim before fresh pending facts. A continuously growing
|
||||||
|
// pending backlog can otherwise fill every batch and permanently strand both
|
||||||
|
// due retries and running rows whose worker lease expired. Keep the states in
|
||||||
|
// separate SKIP LOCKED queries so each branch retains its status-leading index.
|
||||||
claimBranches := []struct {
|
claimBranches := []struct {
|
||||||
query string
|
query string
|
||||||
args []any
|
args []any
|
||||||
}{
|
}{
|
||||||
{
|
|
||||||
query: `
|
|
||||||
SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
|
|
||||||
FROM user_outbox FORCE INDEX (idx_user_outbox_status_created)
|
|
||||||
WHERE app_code = ? AND status = 'pending' AND next_retry_at_ms <= ? AND lock_until_ms = 0
|
|
||||||
ORDER BY created_at_ms ASC, event_id ASC
|
|
||||||
LIMIT ? FOR UPDATE SKIP LOCKED
|
|
||||||
`,
|
|
||||||
args: []any{appCode, nowMS},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
query: `
|
query: `
|
||||||
SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
|
SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
|
||||||
@ -100,6 +94,16 @@ func (r *Repository) ClaimPendingUserOutbox(ctx context.Context, workerID string
|
|||||||
WHERE app_code = ? AND status = 'running' AND lock_until_ms <= ?
|
WHERE app_code = ? AND status = 'running' AND lock_until_ms <= ?
|
||||||
ORDER BY created_at_ms ASC, event_id ASC
|
ORDER BY created_at_ms ASC, event_id ASC
|
||||||
LIMIT ? FOR UPDATE SKIP LOCKED
|
LIMIT ? FOR UPDATE SKIP LOCKED
|
||||||
|
`,
|
||||||
|
args: []any{appCode, nowMS},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: `
|
||||||
|
SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
|
||||||
|
FROM user_outbox FORCE INDEX (idx_user_outbox_status_created)
|
||||||
|
WHERE app_code = ? AND status = 'pending' AND next_retry_at_ms <= ? AND lock_until_ms = 0
|
||||||
|
ORDER BY created_at_ms ASC, event_id ASC
|
||||||
|
LIMIT ? FOR UPDATE SKIP LOCKED
|
||||||
`,
|
`,
|
||||||
args: []any{appCode, nowMS},
|
args: []any{appCode, nowMS},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -0,0 +1,79 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClaimPendingUserOutboxPrioritizesRecoveryOverPendingAtBatchOne(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
eventID string
|
||||||
|
recoveryStatus string
|
||||||
|
expectEmptyRetry bool
|
||||||
|
}{
|
||||||
|
{name: "due retry", eventID: "user-retry", recoveryStatus: "retryable"},
|
||||||
|
{name: "expired run", eventID: "user-expired", recoveryStatus: "running", expectEmptyRetry: true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
if tt.expectEmptyRetry {
|
||||||
|
mock.ExpectQuery(userOutboxRetryClaimPattern()).
|
||||||
|
WithArgs("lalu", sqlmock.AnyArg(), 1).
|
||||||
|
WillReturnRows(userOutboxClaimRows())
|
||||||
|
}
|
||||||
|
claimPattern := userOutboxRetryClaimPattern()
|
||||||
|
if tt.recoveryStatus == "running" {
|
||||||
|
claimPattern = userOutboxExpiredClaimPattern()
|
||||||
|
}
|
||||||
|
mock.ExpectQuery(claimPattern).
|
||||||
|
WithArgs("lalu", sqlmock.AnyArg(), 1).
|
||||||
|
WillReturnRows(userOutboxClaimRows().AddRow(
|
||||||
|
"lalu", tt.eventID, "UserRegistered", "user", int64(1001), `{}`, int64(200),
|
||||||
|
))
|
||||||
|
mock.ExpectExec(`(?s)UPDATE user_outbox\s+SET status = 'running', worker_id = \?, lock_until_ms = \?, updated_at_ms = \?\s+WHERE app_code = \? AND event_id = \?`).
|
||||||
|
WithArgs("worker-1", sqlmock.AnyArg(), sqlmock.AnyArg(), "lalu", tt.eventID).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
records, err := (&Repository{db: db}).ClaimPendingUserOutbox(
|
||||||
|
appcode.WithContext(context.Background(), "lalu"), "worker-1", 1,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("claim user recovery row: %v", err)
|
||||||
|
}
|
||||||
|
if len(records) != 1 || records[0].EventID != tt.eventID {
|
||||||
|
t.Fatalf("batch=1 must claim %s before an older pending row: %+v", tt.recoveryStatus, records)
|
||||||
|
}
|
||||||
|
// No pending query is registered: issuing one after the recovery row filled
|
||||||
|
// the batch makes sqlmock fail, protecting the starvation boundary directly.
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("user claim priority mismatch: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func userOutboxClaimRows() *sqlmock.Rows {
|
||||||
|
return sqlmock.NewRows([]string{
|
||||||
|
"app_code", "event_id", "event_type", "aggregate_type", "aggregate_id", "payload_json", "created_at_ms",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func userOutboxRetryClaimPattern() string {
|
||||||
|
return `(?s)FROM user_outbox FORCE INDEX \(idx_user_outbox_status_created\).*WHERE app_code = \? AND status = 'retryable' AND next_retry_at_ms <= \? AND lock_until_ms = 0.*FOR UPDATE SKIP LOCKED`
|
||||||
|
}
|
||||||
|
|
||||||
|
func userOutboxExpiredClaimPattern() string {
|
||||||
|
return `(?s)FROM user_outbox FORCE INDEX \(idx_user_outbox_lock\).*WHERE app_code = \? AND status = 'running' AND lock_until_ms <= \?.*FOR UPDATE SKIP LOCKED`
|
||||||
|
}
|
||||||
@ -142,16 +142,14 @@ func (r *Repository) ClaimPendingWalletOutboxFiltered(ctx context.Context, worke
|
|||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
records := make([]WalletOutboxRecord, 0, limit)
|
records := make([]WalletOutboxRecord, 0, limit)
|
||||||
|
// Recovery work must claim before fresh pending facts. A continuously growing
|
||||||
|
// pending backlog can otherwise fill every batch and permanently strand both
|
||||||
|
// due retries and deliveries whose worker lease expired. Keep the states in
|
||||||
|
// separate SKIP LOCKED queries so each branch retains its status-leading index.
|
||||||
claimBranches := []struct {
|
claimBranches := []struct {
|
||||||
query string
|
query string
|
||||||
args []any
|
args []any
|
||||||
}{
|
}{
|
||||||
{
|
|
||||||
query: walletOutboxClaimQuery(pendingIndex, `
|
|
||||||
AND next_retry_at_ms IS NULL
|
|
||||||
AND lock_until_ms IS NULL`, eventFilterSQL),
|
|
||||||
args: append([]any{appCode, outboxStatusPending}, eventFilterArgs...),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
query: walletOutboxClaimQuery(pendingIndex, `
|
query: walletOutboxClaimQuery(pendingIndex, `
|
||||||
AND next_retry_at_ms <= ?
|
AND next_retry_at_ms <= ?
|
||||||
@ -160,9 +158,15 @@ func (r *Repository) ClaimPendingWalletOutboxFiltered(ctx context.Context, worke
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
query: walletOutboxClaimQuery(claimIndex, `
|
query: walletOutboxClaimQuery(claimIndex, `
|
||||||
AND lock_until_ms <= ?`, eventFilterSQL),
|
AND lock_until_ms <= ?`, eventFilterSQL),
|
||||||
args: append([]any{appCode, outboxStatusDelivering, nowMS}, eventFilterArgs...),
|
args: append([]any{appCode, outboxStatusDelivering, nowMS}, eventFilterArgs...),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
query: walletOutboxClaimQuery(pendingIndex, `
|
||||||
|
AND next_retry_at_ms IS NULL
|
||||||
|
AND lock_until_ms IS NULL`, eventFilterSQL),
|
||||||
|
args: append([]any{appCode, outboxStatusPending}, eventFilterArgs...),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, branch := range claimBranches {
|
for _, branch := range claimBranches {
|
||||||
remaining := limit - len(records)
|
remaining := limit - len(records)
|
||||||
|
|||||||
@ -0,0 +1,83 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClaimPendingWalletOutboxPrioritizesRecoveryOverPendingAtBatchOne(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
eventID string
|
||||||
|
recoveryStatus string
|
||||||
|
expectEmptyRetry bool
|
||||||
|
}{
|
||||||
|
{name: "due retry", eventID: "wallet-retry", recoveryStatus: outboxStatusRetryable},
|
||||||
|
{name: "expired delivery", eventID: "wallet-expired", recoveryStatus: outboxStatusDelivering, expectEmptyRetry: true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
if tt.expectEmptyRetry {
|
||||||
|
mock.ExpectQuery(walletOutboxRetryClaimPattern()).
|
||||||
|
WithArgs("lalu", outboxStatusRetryable, sqlmock.AnyArg(), 1).
|
||||||
|
WillReturnRows(walletOutboxClaimRows())
|
||||||
|
}
|
||||||
|
claimPattern := walletOutboxRetryClaimPattern()
|
||||||
|
if tt.recoveryStatus == outboxStatusDelivering {
|
||||||
|
claimPattern = walletOutboxExpiredClaimPattern()
|
||||||
|
}
|
||||||
|
mock.ExpectQuery(claimPattern).
|
||||||
|
WithArgs("lalu", tt.recoveryStatus, sqlmock.AnyArg(), 1).
|
||||||
|
WillReturnRows(walletOutboxClaimRows().AddRow(
|
||||||
|
"lalu", tt.eventID, "WalletBalanceChanged", "tx-1", "cmd-1", int64(1001),
|
||||||
|
"COIN", int64(1), int64(0), `{}`, int64(200), 0, "", "old-worker", int64(100),
|
||||||
|
))
|
||||||
|
lockUntilMS := time.Now().UTC().Add(time.Minute).UnixMilli()
|
||||||
|
mock.ExpectExec(`(?s)UPDATE wallet_outbox\s+SET status = \?, worker_id = \?, lock_until_ms = \?, updated_at_ms = \?\s+WHERE app_code = \? AND event_id = \?`).
|
||||||
|
WithArgs(outboxStatusDelivering, "worker-1", lockUntilMS, sqlmock.AnyArg(), "lalu", tt.eventID).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
records, err := (&Repository{db: db}).ClaimPendingWalletOutbox(
|
||||||
|
appcode.WithContext(context.Background(), "lalu"), "worker-1", 1, lockUntilMS,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("claim wallet recovery row: %v", err)
|
||||||
|
}
|
||||||
|
if len(records) != 1 || records[0].EventID != tt.eventID {
|
||||||
|
t.Fatalf("batch=1 must claim %s before an older pending row: %+v", tt.recoveryStatus, records)
|
||||||
|
}
|
||||||
|
// No pending query is registered: issuing one after the recovery row filled
|
||||||
|
// the batch makes sqlmock fail, protecting the starvation boundary directly.
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("wallet claim priority mismatch: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func walletOutboxClaimRows() *sqlmock.Rows {
|
||||||
|
return sqlmock.NewRows([]string{
|
||||||
|
"app_code", "event_id", "event_type", "transaction_id", "command_id", "user_id", "asset_type",
|
||||||
|
"available_delta", "frozen_delta", "payload", "created_at_ms", "retry_count", "last_error", "worker_id", "lock_until_ms",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func walletOutboxRetryClaimPattern() string {
|
||||||
|
return `(?s)FROM wallet_outbox FORCE INDEX \(idx_wallet_outbox_pending\).*WHERE app_code = \? AND status = \?\s+AND next_retry_at_ms <= \?\s+AND lock_until_ms IS NULL.*FOR UPDATE SKIP LOCKED`
|
||||||
|
}
|
||||||
|
|
||||||
|
func walletOutboxExpiredClaimPattern() string {
|
||||||
|
return `(?s)FROM wallet_outbox FORCE INDEX \(idx_wallet_outbox_claim\).*WHERE app_code = \? AND status = \?\s+AND lock_until_ms <= \?.*FOR UPDATE SKIP LOCKED`
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user