🐛(backend) exclude hard-deleted items from storage computation

Hard-deleted items no longer occupy storage but still counted in the
creator's usage, inflating the quota gauge and blocking uploads for
users who had cleaned up their trash. The new partial covering index
keeps the per-creator sum an index-only scan now that the quota check
runs it on every uncached upload.
This commit is contained in:
Nathan Vasse
2026-07-23 17:08:10 +02:00
parent 780c63bb08
commit 6c61275669
4 changed files with 73 additions and 3 deletions
@@ -0,0 +1,22 @@
# Generated by Django 5.2.14 on 2026-07-08 10:14
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
class Migration(migrations.Migration):
# CREATE INDEX CONCURRENTLY cannot run inside a transaction. It avoids
# locking writes on the item table while the index is being built.
atomic = False
dependencies = [
('core', '0025_user_storage_limit_override'),
]
operations = [
AddIndexConcurrently(
model_name='item',
index=models.Index(condition=models.Q(('hard_deleted_at__isnull', True)), fields=['creator'], include=('size',), name='item_creator_size_not_hdel_idx'),
),
]
+7
View File
@@ -1034,6 +1034,13 @@ class Item(TreeModel, BaseModel):
indexes = [
GistIndex(fields=["path"]),
models.Index(NLevel(models.F("path")), name="drive_item_path_nlevel_idx"),
# Covers the storage used computation by creator.
models.Index(
fields=["creator"],
include=["size"],
condition=models.Q(hard_deleted_at__isnull=True),
name="item_creator_size_not_hdel_idx",
),
]
def __str__(self):
@@ -15,6 +15,6 @@ class CreatorStorageComputeBackend(StorageComputeBackend):
"""
Compute the total storage used by a set of users.
"""
return Item.objects.filter(creator__in=users).aggregate(total_size=Sum("size", default=0))[
"total_size"
]
return Item.objects.filter(creator__in=users, hard_deleted_at__isnull=True).aggregate(
total_size=Sum("size", default=0)
)["total_size"]
@@ -0,0 +1,41 @@
"""
Tests for the CreatorStorageComputeBackend.
"""
import pytest
from core import factories
from core.storage.creator_storage_compute_backend import CreatorStorageComputeBackend
pytestmark = pytest.mark.django_db
def test_compute_storage_used_sums_creator_items():
"""The backend should sum the sizes of all items created by the given users."""
user = factories.UserFactory()
factories.ItemFactory(creator=user, size=100)
factories.ItemFactory(creator=user, size=250)
factories.ItemFactory(size=999) # another creator, should not count
assert CreatorStorageComputeBackend().compute_storage_used([user]) == 350
def test_compute_storage_used_excludes_hard_deleted_items():
"""Hard-deleted items should not count toward the storage used."""
user = factories.UserFactory()
factories.ItemFactory(creator=user, size=100)
hard_deleted = factories.ItemFactory(creator=user, size=250)
hard_deleted.soft_delete()
hard_deleted.hard_delete()
assert CreatorStorageComputeBackend().compute_storage_used([user]) == 100
def test_compute_storage_used_keeps_soft_deleted_items():
"""Soft-deleted (trashbin) items should still count toward the storage used."""
user = factories.UserFactory()
factories.ItemFactory(creator=user, size=100)
soft_deleted = factories.ItemFactory(creator=user, size=250)
soft_deleted.soft_delete()
assert CreatorStorageComputeBackend().compute_storage_used([user]) == 350