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:
parent
696cfe7d65
commit
d1b6a19eb3
4 changed files with 195 additions and 36 deletions
|
|
@ -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.")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue