leader_lock.go 2.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "time"
  6. )
  7. // LeaderLockCache provides cross-instance mutual exclusion for periodic background
  8. // jobs. It is implemented in the repository layer (Redis-backed) so the service
  9. // layer never depends on Redis directly. Release is a compare-and-delete keyed by
  10. // owner so a stale holder can never delete a peer's lock.
  11. type LeaderLockCache interface {
  12. // TryAcquireLeaderLock sets key=owner with the given TTL iff key is absent.
  13. // It returns true when the caller becomes the owner.
  14. TryAcquireLeaderLock(ctx context.Context, key, owner string, ttl time.Duration) (bool, error)
  15. // ReleaseLeaderLock deletes key iff it is still owned by owner.
  16. ReleaseLeaderLock(ctx context.Context, key, owner string) error
  17. }
  18. // tryAcquireSingletonLeaderLock provides best-effort single-flight execution of a
  19. // periodic background job across multiple instances. It prefers the Redis-backed
  20. // LeaderLockCache and falls back to a Postgres advisory lock when the cache is
  21. // unavailable or errors, mirroring the approach used by the Ops background
  22. // services.
  23. //
  24. // Semantics:
  25. // - acquired -> returns a non-nil release func and true; callers should
  26. // defer the release once the job finishes.
  27. // - held by peer -> returns (nil, false); callers should skip this cycle.
  28. // - no backend -> when neither the cache nor a DB is configured (e.g. unit
  29. // tests, or a single-instance deployment without Redis) it runs without
  30. // gating, returning a no-op release and true, so the job is never silently
  31. // starved.
  32. //
  33. // The TTL is purely a crash-safety bound: callers release the lock as soon as the
  34. // job completes, so leadership is re-contested every cycle rather than pinned to
  35. // one instance. The TTL must therefore be larger than the job's worst-case
  36. // runtime so the lock does not expire mid-run.
  37. func tryAcquireSingletonLeaderLock(ctx context.Context, cache LeaderLockCache, db *sql.DB, key, owner string, ttl time.Duration) (func(), bool) {
  38. if ctx == nil {
  39. ctx = context.Background()
  40. }
  41. if cache != nil {
  42. ok, err := cache.TryAcquireLeaderLock(ctx, key, owner, ttl)
  43. if err == nil {
  44. if !ok {
  45. return nil, false
  46. }
  47. release := func() {
  48. ctx2, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  49. defer cancel()
  50. _ = cache.ReleaseLeaderLock(ctx2, key, owner)
  51. }
  52. return release, true
  53. }
  54. // Cache error: fall through to the DB advisory lock so a flaky Redis does
  55. // not stampede the job across every instance.
  56. }
  57. if db != nil {
  58. return tryAcquireDBAdvisoryLock(ctx, db, hashAdvisoryLockID(key))
  59. }
  60. // No coordination backend available: run without gating.
  61. return func() {}, true
  62. }