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> |
||
|---|---|---|
| .. | ||
| 001-csrf-behind-reverse-proxy.md | ||
| 002-env-example-not-actually-loaded.md | ||
| 003-model-max-length-silently-unenforced.md | ||
| 004-html-fragment-content-not-validated-server-side.md | ||
| 005-patch-silently-mishandles-invalid-input.md | ||
| 006-failed-post-still-evicts-artifacts.md | ||
| 007-handover-to-llm-test-2026-09-02.md | ||
| 010-concurrent-posts-race-past-quota.md | ||
| README.md | ||
Issues
One file per issue: NNN-short-slug.md, numbered sequentially. Each file
has a title, status (Open / Fixed / By design), description, and resolution
once closed.