Three periodic background jobs ran on every instance with no cross-instance coordination, multiplying their cost (and side effects) by the replica count: - DashboardAggregationService.runScheduledAggregation: N× heavy GROUP BY aggregation queries every minute plus watermark write races. - PaymentOrderExpiryService.runOnce: N× upstream payment-provider reconcile/ expiry API calls per pending order. - SubscriptionExpiryService.sendExpiryReminders: N× full active-subscription scans every minute and potential duplicate reminder emails. Add a LeaderLockCache abstraction so only one instance runs each job per cycle: - The interface lives in the service layer; the Redis-backed implementation (SetNX + compare-and-delete release) lives in the repository layer, so the service package keeps its depguard "must not import redis" boundary intact. - tryAcquireSingletonLeaderLock prefers the cache and falls back to a Postgres advisory lock when Redis errors, mirroring the Ops background services. When neither backend is configured the job runs ungated, preserving single-instance and test behavior (no self-lockout: the lock is released every cycle). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
43 lines
1.3 KiB
Go
43 lines
1.3 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
const leaderLockKeyPrefix = "leader:lock:"
|
|
|
|
// leaderLockReleaseScript releases a leader lock only when the caller still owns
|
|
// it (compare-and-delete by owner token). This prevents a previous holder whose
|
|
// lock already expired — and was re-acquired by another instance — from deleting
|
|
// the new owner's lock.
|
|
var leaderLockReleaseScript = redis.NewScript(`
|
|
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
return redis.call("DEL", KEYS[1])
|
|
end
|
|
return 0
|
|
`)
|
|
|
|
type leaderLockCache struct {
|
|
rdb *redis.Client
|
|
}
|
|
|
|
// NewLeaderLockCache returns a Redis-backed implementation of
|
|
// service.LeaderLockCache used by periodic background jobs to elect a single
|
|
// runner across instances.
|
|
func NewLeaderLockCache(rdb *redis.Client) service.LeaderLockCache {
|
|
return &leaderLockCache{rdb: rdb}
|
|
}
|
|
|
|
func (c *leaderLockCache) TryAcquireLeaderLock(ctx context.Context, key, owner string, ttl time.Duration) (bool, error) {
|
|
return c.rdb.SetNX(ctx, leaderLockKeyPrefix+key, owner, ttl).Result()
|
|
}
|
|
|
|
func (c *leaderLockCache) ReleaseLeaderLock(ctx context.Context, key, owner string) error {
|
|
return leaderLockReleaseScript.Run(ctx, c.rdb, []string{leaderLockKeyPrefix + key}, owner).Err()
|
|
}
|