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
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue