Transactions, Locking & Concurrency in Code · Locking Strategies

Advisory Locks for Run-Once Jobs

Stop a nightly billing job from running on three servers at once, with Postgres advisory locks and MySQL GET_LOCK.

Scenario

Example app: a SaaS platform runs a nightly billing job at 02:00 that charges every tenant whose renewal date is today. The job is a scheduled task inside the web app — and the app runs on three instances.

What you see in production

Every tenant renewing on the 1st was charged three times. Each instance's scheduler fired at 02:00 and processed the same tenants.

The naive code

@scheduler.scheduled_job("cron", hour=2)
def nightly_billing():
    for tenant in Tenant.objects.filter(renews_on=date.today()):
        charge(tenant)

Reproduce it

Three instances start the job at the same second. With a try-lock, only one proceeds:

Three schedulers, one advisory lockTry it yourself
advisory_locks = {}                         # key -> session holding it

def pg_try_advisory_lock(session, key):
    if key in advisory_locks:
        return False
    advisory_locks[key] = session
    return True

def pg_advisory_unlock(session, key):
    if advisory_locks.get(key) == session:
        del advisory_locks[key]

BILLING_LOCK = 4242                          # any agreed 64-bit number per job

for instance in ("web-1", "web-2", "web-3"):
    if pg_try_advisory_lock(instance, BILLING_LOCK):
        print(f"{instance}: got the lock -> billing 120 tenants")
    else:
        print(f"{instance}: another instance is billing -> skip")
pg_advisory_unlock("web-1", BILLING_LOCK)
print("after web-1 finishes, lock held by:", advisory_locks.get(BILLING_LOCK))

Output

web-1: got the lock -> billing 120 tenants
web-2: another instance is billing -> skip
web-3: another instance is billing -> skip
after web-1 finishes, lock held by: None

Root cause

Every instance runs the same scheduler, and nothing coordinates them. Row locks don't help — there's no single row that represents "the billing job". Advisory locks are named locks managed by the database whose meaning is defined by your application: all instances agree that lock 4242 means "nightly billing is running".

The fix

-- session-level: held until unlocked or the connection closes
SELECT pg_try_advisory_lock(4242);      -- true for exactly one session
-- … do the work …
SELECT pg_advisory_unlock(4242);

-- transaction-level: released automatically at commit/rollback (safer)
BEGIN;
SELECT pg_try_advisory_xact_lock(hashtext('billing:2026-10-13'));
COMMIT;

Even with the lock, make billing idempotent per tenant and period — a unique key on (tenant_id, period) in the invoices table — so a crash-and-rerun can't double-charge.

Trade-offs

  • Session-level locks + poolers: behind PgBouncer in transaction mode, a session lock taken on one server connection may be "held" by a connection your next query doesn't use. Prefer transaction-level locks (pg_advisory_xact_lock) there, or run the job on a direct connection.
  • If the holder's connection drops, the lock is released while the job may still be running on that instance → add per-item idempotency.
  • Advisory locks only coordinate code that asks for them; they don't protect rows from other writers.
  • For locks across services or databases, use a lease with fencing tokens (see Distributed Systems).

Detect it in production

  • Postgres: SELECT * FROM pg_locks WHERE locktype = 'advisory'; shows holders.
  • Log "acquired" and "skipped" per run on every instance; alert if more than one instance reports "acquired" for the same run, or none does.
  • Invoices table invariant: no duplicate (tenant_id, period).

Key points

  • Schedulers on every instance run the job on every instance.
  • Advisory locks (pg_try_advisory_lock, MySQL GET_LOCK) let exactly one instance proceed.
  • Prefer transaction-level locks behind poolers; know what happens on disconnect.
  • Keep per-item idempotency as the safety net.

Practice questions

A scheduled job runs on all three app instances and triples the work. How can the database coordinate them?

Answer

Have each instance call pg_try_advisory_lock(<job key>) (MySQL: GET_LOCK(name, 0)) before running; only one gets true and proceeds, the others skip.

Why can session-level advisory locks misbehave behind PgBouncer in transaction mode?

Answer

The pooler may run the lock and unlock (or the work) on different server connections, so the lock is held by a connection the app no longer controls. Use transaction-level advisory locks or a dedicated session connection.

Why should a job protected by an advisory lock still be idempotent?

Answer

Locks can be lost (connection drops, timeouts) while work continues, and jobs get re-run after crashes. A unique key per item and period makes repeated processing harmless.

Open in the app to mark lessons complete, take notes and track your progress.