Fix concurrent posts racing past quota (issue #010)

make_room_for() read usage before any write lock was held, so concurrent
requests could each read stale data and both conclude "this fits" -
confirmed reproducible (measured 616 bytes over a 1551-byte quota with 5
truly concurrent posts).

Fix: force SQLite to acquire its write lock immediately via a no-op UPDATE
on the user's UserProfile row, before reading `used`, wrapped in
transaction.atomic() through the subsequent Artifact.objects.create() so
the lock is held across the whole eviction+insert sequence. Added a 20s
DB timeout as headroom for legitimate contention.

Deliberately no retry/backoff logic - this project's actual load (a
low-volume self-hosted tool) doesn't need it, and SQLite has no row-level
locking regardless, so quota-affecting writes serialize globally either
way. Did fix a real bug found while verifying this: exception handling
around artifact creation only caught QuotaExceeded, so a genuine lock
timeout left the just-written file orphaned on disk - both call sites now
clean up on any exception.

Verified live against the real dev instance (twice, with 5 truly
simultaneous posts against a tight quota) rather than via an automated
test - Django's in-memory shared-cache test database uses a different,
non-retryable SQLite locking model that fails for reasons unrelated to
this fix's correctness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-02 15:22:37 +01:00
parent 696cfe7d65
commit d1b6a19eb3
4 changed files with 195 additions and 36 deletions

View file

@ -10,7 +10,7 @@ import shutil
from datetime import timedelta
from django.conf import settings
from django.db.models import Sum
from django.db.models import F, Sum
from django.utils import timezone
from .models import Artifact, Asset, UserProfile
@ -91,7 +91,20 @@ def make_room_for(user, incoming_bytes):
instance's pk on .delete(), so ids are captured beforehand). Raises
QuotaExceeded, without deleting anything, if even evicting every unkept
artifact wouldn't free enough room.
Must be called inside `transaction.atomic()` by the caller, wrapping
through the subsequent `Artifact.objects.create()` too. The UPDATE below
is a real write (not a plain SELECT) specifically so SQLite acquires its
write lock *before* `used` is read - without that, two concurrent
requests can each read a stale `used` during their unlocked read phase
and both conclude "this fits" (issue #010). SQLite has no row-level
locking, so this necessarily serializes quota-affecting writes across
*all* users, not just the current one - an inherent SQLite limitation,
acceptable at this project's scale.
"""
UserProfile.objects.get_or_create(user=user)
UserProfile.objects.filter(user=user).update(quota_bytes=F("quota_bytes"))
quota = user_quota_bytes(user)
if incoming_bytes > quota:
raise QuotaExceeded("Artifact is larger than the entire quota.")