fix(db): report lock contention instead of throwing
Summary
DatabaseTaskBackend.acquire_lock throws instead of reporting contention, on
the ordinary path, with no fault involved.
AI disclosure: Claude Code — found it via the gauntlet, wrote the fix and the test, and drafted this description.
The bug
except VTaskMetadata.DoesNotExist:
VTaskMetadata.objects.create(...) # ← IntegrityError escapes from here
return True
except DatabaseError:
# This can happen if another process created the lock row concurrently.
return FalseThe comment names exactly this race, and IntegrityError is a
DatabaseError — but a sibling except clause cannot catch anything raised
from inside another handler, so it escapes regardless.
What makes it the ordinary path rather than an edge case is skip_locked:
while a sibling holds the row, the SELECT skips it and .get() raises
DoesNotExist even though the row plainly exists. So under normal two-worker
contention the loser throws every time.
The same lock gates the periodic rescue sweep, the scheduler and the delayed-task promoter, so a lost race silently costs that tick of all three.
How it was found
The gauntlet's db-baseline scenario — no faults at all, two workers:
Periodic rescue sweep failed
Traceback (most recent call last):
File "django_vtasks/backends/db.py", line 231, in acquire_lock
lock = VTaskMetadata.objects.select_for_update(**self._get_lock_params()).get(key=key)
django_vtasks.db.models.VTaskMetadata.DoesNotExist: ...
During handling of the above exception, another exception occurred:
django.db.utils.IntegrityError: duplicate key value violates unique constraint
"db_vtaskmetadata_pkey"
DETAIL: Key (key)=(vtasks_rescue_lock) already exists.It was on the harness's ranked list of things found by inspection; this is the first time it has been demonstrated running.
The fix
The insert moves into a savepoint. That is not incidental: catching a database
error inside atomic() without one poisons the transaction, which would move
the failure from acquire_lock to COMMIT — a fix that only relocates the
crash.
The except DatabaseError clause stays for genuine failures in the SELECT or
the refresh, with its comment corrected, since the case it described can no
longer reach it.
Test
test_losing_the_insert_race_is_contention_not_an_error reproduces the
production path rather than simulating it: a holder thread takes a blocking
select_for_update on the row, so the skip_locked reader genuinely misses a
row that exists, and the insert genuinely hits the primary key. Verified red
against the unfixed handler with the real IntegrityError, green with the fix.
It also asserts a query after the lost race, which is what pins the savepoint
— without it the transaction is left unusable.
PostgreSQL-only (it needs skip_locked to make the SELECT miss); skips
elsewhere.
Verification
- 204 tests OK on Postgres 18 + Valkey 9; 185 OK on SQLite (12 skipped)
ruff check/ruff format --checkclean