| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768 |
- package service
- import (
- "context"
- "database/sql"
- "time"
- )
- // LeaderLockCache provides cross-instance mutual exclusion for periodic background
- // jobs. It is implemented in the repository layer (Redis-backed) so the service
- // layer never depends on Redis directly. Release is a compare-and-delete keyed by
- // owner so a stale holder can never delete a peer's lock.
- type LeaderLockCache interface {
- // TryAcquireLeaderLock sets key=owner with the given TTL iff key is absent.
- // It returns true when the caller becomes the owner.
- TryAcquireLeaderLock(ctx context.Context, key, owner string, ttl time.Duration) (bool, error)
- // ReleaseLeaderLock deletes key iff it is still owned by owner.
- ReleaseLeaderLock(ctx context.Context, key, owner string) error
- }
- // tryAcquireSingletonLeaderLock provides best-effort single-flight execution of a
- // periodic background job across multiple instances. It prefers the Redis-backed
- // LeaderLockCache and falls back to a Postgres advisory lock when the cache is
- // unavailable or errors, mirroring the approach used by the Ops background
- // services.
- //
- // Semantics:
- // - acquired -> returns a non-nil release func and true; callers should
- // defer the release once the job finishes.
- // - held by peer -> returns (nil, false); callers should skip this cycle.
- // - no backend -> when neither the cache nor a DB is configured (e.g. unit
- // tests, or a single-instance deployment without Redis) it runs without
- // gating, returning a no-op release and true, so the job is never silently
- // starved.
- //
- // The TTL is purely a crash-safety bound: callers release the lock as soon as the
- // job completes, so leadership is re-contested every cycle rather than pinned to
- // one instance. The TTL must therefore be larger than the job's worst-case
- // runtime so the lock does not expire mid-run.
- func tryAcquireSingletonLeaderLock(ctx context.Context, cache LeaderLockCache, db *sql.DB, key, owner string, ttl time.Duration) (func(), bool) {
- if ctx == nil {
- ctx = context.Background()
- }
- if cache != nil {
- ok, err := cache.TryAcquireLeaderLock(ctx, key, owner, ttl)
- if err == nil {
- if !ok {
- return nil, false
- }
- release := func() {
- ctx2, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- _ = cache.ReleaseLeaderLock(ctx2, key, owner)
- }
- return release, true
- }
- // Cache error: fall through to the DB advisory lock so a flaky Redis does
- // not stampede the job across every instance.
- }
- if db != nil {
- return tryAcquireDBAdvisoryLock(ctx, db, hashAdvisoryLockID(key))
- }
- // No coordination backend available: run without gating.
- return func() {}, true
- }
|