Add per-user disk quotas, auto-eviction, and Keep flag
Bounds each user's disk footprint and makes throwaway artifacts self-clean:
- Per-user quota (default 1 GB) stored on a new UserProfile model,
adjustable per user in Django admin. Artifact.size_bytes records each
artifact's on-disk footprint (primary file + markdown sidecar) at
ingest so usage is a cheap SUM.
- Automatic eviction: on each post, oldest unkept artifacts are deleted
(files and rows) until the new one fits under quota. If the quota is
full of kept artifacts, the post is rejected with HTTP 413 rather than
evicting anything protected.
- Keep flag (Artifact.is_kept): a Keep button in the preview control bar
toggles protection via POST /a/{id}/keep; kept items are never evicted.
Kept rows show a green "K" badge in the listing.
- Details panel: a Details toggle in the control bar expands a panel
under it with full source, type, created time, size, and tags.
- Settings -> Storage: usage bar (green/amber/red), used/quota summary,
artifact and kept counts; superusers also see total allocated quota vs
free disk with an oversubscription warning.
- Type indicator in the listing is now a small grey pill instead of
plain text.
Storage accounting/eviction logic lives in artifacts/storage.py.
Migrations 0009 (fields + UserProfile) and 0010 (backfill sizes and
create profiles for existing users). Fresh installs no-op the backfill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
37cd96fa7a
commit
e0f1cce626
12 changed files with 517 additions and 12 deletions
92
artifacts/storage.py
Normal file
92
artifacts/storage.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Disk-usage accounting, per-user quota, and automatic eviction.
|
||||
|
||||
An artifact's on-disk footprint is the primary stored file plus, for markdown,
|
||||
the preserved `.md` sidecar. We record it on the row (`size_bytes`) at ingest so
|
||||
usage is a cheap SUM rather than a stat of every file.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Sum
|
||||
|
||||
from .models import Artifact, UserProfile
|
||||
|
||||
|
||||
class QuotaExceeded(Exception):
|
||||
"""Raised when a post cannot fit even after evicting every unkept artifact."""
|
||||
|
||||
|
||||
def artifact_disk_size(relative_path, kind):
|
||||
"""Bytes an artifact occupies on disk, including the markdown sidecar."""
|
||||
root = settings.ARTIFACTS_ROOT
|
||||
total = 0
|
||||
primary = root / relative_path
|
||||
if primary.exists():
|
||||
total += primary.stat().st_size
|
||||
if kind == Artifact.MARKDOWN:
|
||||
sidecar = primary.with_suffix(".md")
|
||||
if sidecar.exists():
|
||||
total += sidecar.stat().st_size
|
||||
return total
|
||||
|
||||
|
||||
def delete_artifact_files(artifact):
|
||||
"""Remove an artifact's files from disk (primary + markdown sidecar)."""
|
||||
root = settings.ARTIFACTS_ROOT
|
||||
primary = root / artifact.path
|
||||
primary.unlink(missing_ok=True)
|
||||
if artifact.kind == Artifact.MARKDOWN:
|
||||
primary.with_suffix(".md").unlink(missing_ok=True)
|
||||
|
||||
|
||||
def user_usage_bytes(user):
|
||||
return (
|
||||
Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total")
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def user_quota_bytes(user):
|
||||
profile, _ = UserProfile.objects.get_or_create(user=user)
|
||||
return profile.quota_bytes
|
||||
|
||||
|
||||
def make_room_for(user, incoming_bytes):
|
||||
"""Evict oldest unkept artifacts until `incoming_bytes` fits under quota.
|
||||
|
||||
Returns the list of evicted artifacts (already deleted). Raises QuotaExceeded
|
||||
if even removing every unkept artifact leaves no room.
|
||||
"""
|
||||
quota = user_quota_bytes(user)
|
||||
if incoming_bytes > quota:
|
||||
raise QuotaExceeded("Artifact is larger than the entire quota.")
|
||||
|
||||
used = user_usage_bytes(user)
|
||||
if used + incoming_bytes <= quota:
|
||||
return []
|
||||
|
||||
evicted = []
|
||||
unkept = (
|
||||
Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at")
|
||||
)
|
||||
for artifact in unkept:
|
||||
if used + incoming_bytes <= quota:
|
||||
break
|
||||
used -= artifact.size_bytes
|
||||
delete_artifact_files(artifact)
|
||||
artifact.delete()
|
||||
evicted.append(artifact)
|
||||
|
||||
if used + incoming_bytes > quota:
|
||||
raise QuotaExceeded("Quota is full of kept artifacts; free space to post.")
|
||||
return evicted
|
||||
|
||||
|
||||
def disk_free_bytes():
|
||||
"""Free space on the volume that holds the artifact store."""
|
||||
return shutil.disk_usage(settings.ARTIFACTS_ROOT).free
|
||||
|
||||
|
||||
def total_allocated_bytes():
|
||||
return UserProfile.objects.aggregate(total=Sum("quota_bytes")).get("total") or 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue