- #006 (high, data loss): make_room_for() no longer deletes unkept artifacts on a doomed request - checks whether eviction can possibly succeed before deleting anything. Also fixed a related latent bug found while testing: the function returned evicted Artifact objects after calling .delete() on them, so their .id was always None (Django clears pk on delete); now returns ids captured beforehand. - #003: added server-side field-length validation (source/title/ source_name/source_id/theme) on POST and PATCH, matching the model's own max_length limits, which were previously unenforced on the ingest path entirely. - #004: ported daybook_post.py's content-shape validation (html_fragment/html_full/deck/markdown) to the server, closing the gap for any client that doesn't go through the helper script. - #005: PATCH now rejects wrong-typed `tags` (previously silently wiped existing tags) and rejects immutable fields (content/source/kind/day) with 400 instead of silently no-op'ing them. - #002: deleted the misleading skills/daybook-posting/.env.example (a zero-dependency script that never reads .env files) and clarified SKILL.md/docs/llm_posting_helper.md that these must be real process/shell env vars. Adds tests/unit/ for storage-layer tests. 46 tests passing (was 25). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
143 lines
4.7 KiB
Python
143 lines
4.7 KiB
Python
"""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 datetime import timedelta
|
|
|
|
from django.conf import settings
|
|
from django.db.models import Sum
|
|
from django.utils import timezone
|
|
|
|
from .models import Artifact, Asset, 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, assets).
|
|
|
|
Assets are cascade-deleted from the DB by the FK, but their files must be
|
|
removed here explicitly.
|
|
"""
|
|
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)
|
|
for asset in artifact.assets.all():
|
|
delete_asset_file(asset)
|
|
|
|
|
|
def delete_asset_file(asset):
|
|
(settings.ARTIFACTS_ROOT / asset.path).unlink(missing_ok=True)
|
|
|
|
|
|
def artifact_total_bytes(artifact):
|
|
"""An artifact's own bytes plus those of the assets embedded in it."""
|
|
asset_bytes = artifact.assets.aggregate(total=Sum("size_bytes")).get("total") or 0
|
|
return artifact.size_bytes + asset_bytes
|
|
|
|
|
|
def user_usage_bytes(user):
|
|
artifact_bytes = (
|
|
Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
|
|
)
|
|
asset_bytes = (
|
|
Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
|
|
)
|
|
return artifact_bytes + asset_bytes
|
|
|
|
|
|
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 ids of evicted artifacts (already deleted - Django clears an
|
|
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.
|
|
"""
|
|
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 []
|
|
|
|
unkept = list(
|
|
Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at")
|
|
)
|
|
# Check whether eviction can possibly succeed before deleting anything -
|
|
# otherwise a doomed request would still destroy every unkept artifact on
|
|
# its way to failing anyway.
|
|
evictable_total = sum(artifact_total_bytes(artifact) for artifact in unkept)
|
|
if used - evictable_total + incoming_bytes > quota:
|
|
raise QuotaExceeded("Quota is full of kept artifacts; free space to post.")
|
|
|
|
evicted = []
|
|
for artifact in unkept:
|
|
if used + incoming_bytes <= quota:
|
|
break
|
|
# Freeing an artifact also frees the assets embedded in it.
|
|
used -= artifact_total_bytes(artifact)
|
|
evicted_id = artifact.id
|
|
delete_artifact_files(artifact)
|
|
artifact.delete()
|
|
evicted.append(evicted_id)
|
|
|
|
return evicted
|
|
|
|
|
|
ASSET_ORPHAN_GRACE = timedelta(hours=24)
|
|
|
|
|
|
def sweep_orphan_assets(user=None):
|
|
"""Delete assets uploaded but never referenced by an artifact.
|
|
|
|
Runs opportunistically on upload. The grace period gives an agent time to
|
|
upload images and then post the artifact that references them.
|
|
"""
|
|
cutoff = timezone.now() - ASSET_ORPHAN_GRACE
|
|
orphans = Asset.objects.filter(artifact__isnull=True, created_at__lt=cutoff)
|
|
if user is not None:
|
|
orphans = orphans.filter(owner=user)
|
|
count = 0
|
|
for asset in orphans:
|
|
delete_asset_file(asset)
|
|
asset.delete()
|
|
count += 1
|
|
return count
|
|
|
|
|
|
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
|