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.")

View file

@ -6,6 +6,7 @@ import json
import mimetypes
import re
import secrets
import time
from datetime import timedelta
from pathlib import Path
from urllib.parse import urlencode
@ -16,6 +17,7 @@ from django.contrib import messages
from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.db import OperationalError, transaction
from django.db.models import Count, Q, Sum
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
@ -541,30 +543,37 @@ def create_text_artifact(data, request, user):
stored_path = directory / f"{artifact_id}.html"
(settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8")
def cleanup_files():
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
if sidecar_suffix:
(target_dir / f"{artifact_id}{sidecar_suffix}").unlink(missing_ok=True)
size_bytes = artifact_disk_size(stored_path.as_posix(), kind)
try:
make_room_for(user, size_bytes)
with transaction.atomic():
make_room_for(user, size_bytes)
artifact = Artifact.objects.create(
id=artifact_id,
owner=user,
source=source,
title=title,
kind=kind,
theme=theme,
day=now.date(),
path=stored_path.as_posix(),
original_kind=kind,
tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""),
size_bytes=size_bytes,
**source_fields(data, source),
)
except QuotaExceeded as exc:
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
if kind == Artifact.MARKDOWN:
(target_dir / f"{artifact_id}.md").unlink(missing_ok=True)
cleanup_files()
return Response({"detail": str(exc)}, status=413)
except Exception:
cleanup_files()
raise
artifact = Artifact.objects.create(
id=artifact_id,
owner=user,
source=source,
title=title,
kind=kind,
theme=theme,
day=now.date(),
path=stored_path.as_posix(),
original_kind=kind,
tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""),
size_bytes=size_bytes,
**source_fields(data, source),
)
attach_referenced_assets(artifact, content)
return artifact
@ -600,26 +609,31 @@ def create_binary_artifact(data, user):
size_bytes = artifact_disk_size(stored_path.as_posix(), kind)
try:
make_room_for(user, size_bytes)
with transaction.atomic():
make_room_for(user, size_bytes)
artifact = Artifact.objects.create(
id=artifact_id,
owner=user,
source=source,
title=title,
kind=kind,
theme="",
day=now.date(),
path=stored_path.as_posix(),
original_kind=kind,
tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""),
size_bytes=size_bytes,
**source_fields(data, source),
)
except QuotaExceeded as exc:
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
return Response({"detail": str(exc)}, status=413)
except Exception:
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
raise
return Artifact.objects.create(
id=artifact_id,
owner=user,
source=source,
title=title,
kind=kind,
theme="",
day=now.date(),
path=stored_path.as_posix(),
original_kind=kind,
tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""),
size_bytes=size_bytes,
**source_fields(data, source),
)
return artifact
def wrap_html(body, title, theme, request):