Skip to main content

Use select_for_update for a contested state transition

2026-11-11 16:36:25.633806+00:00
Use select_for_update for a contested state transition

Two workers can both read "pending" and both begin work if claiming a job is a read followed by an unrelated write. Lock the selected row inside a transaction and recheck its state before recording the claim.

Contending workers serialize their state check at the locked job row.

Keep the claim transaction short

This Django fragment assumes a pre-existing toy Job row with state and claimed_by fields, backed by PostgreSQL.

from django.db import transaction

def claim_job(job_id, worker_id):
    with transaction.atomic():
        job = Job.objects.select_for_update().get(pk=job_id)
        if job.state != "pending":
            return False
        job.state = "running"
        job.claimed_by = worker_id
        job.save(update_fields=["state", "claimed_by"])
        return True

The queryset is evaluated inside atomic(). A second transaction targeting the same row waits for the lock and then sees the committed state, so it can decline the already-claimed job.

This claim transaction should finish before long external work begins. Holding a database lock while waiting for an encoder or provider response can block other application operations and consume a connection. Persist the claim, release the transaction and then perform work under an explicit recovery policy.

For queues that select any available job, skip_locked can help workers avoid waiting on rows already claimed by other transactions. Its behavior and support are backend-specific, so the polling query and order need their own integration test.

Test actual contention

Use two independent database connections and coordinate them so the second attempt reaches the locked row before the first commits. Confirm only one claim succeeds. A sequential unit test proves the state check, but it does not exercise contention.

SQLite does not provide the same row-locking behavior through select_for_update. Django's ordinary TestCase also wraps tests in transactions, which can conceal an accidental missing atomic block. Use the appropriate transaction-aware test tools on the intended database.

A claimed state does not prove work will finish. A process can die immediately after claiming. Store enough timing and ownership information to detect abandoned work, and define when an operator or reconciler may retry it.

If a stale worker can still finish after reassignment, use a claim generation or token in completion updates so it cannot overwrite the newer owner's state.

The worker coordination in Postzyo is related context. Django's select_for_update reference documents locks and testing limitations.