GooDrive/backend/drive/quotas.py
Louis Mazin 90004d4540 nickel
2026-10-14 17:27:37 +02:00

183 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Space quotas, including logical descendants, trash, and pending uploads.
A child can be constrained by both its own allocation and its ancestors'.
Call assert_space_capacity() inside transaction.atomic() before reserving/writing.
"""
from contextlib import contextmanager
from django.db import transaction
from django.db.models import Sum
from .models import DriveItem, DriveSpace, UploadSession
MAX_QUOTA_BYTES = 9_007_199_254_740_991 # exact integer in JS too
PRIVATE_SPACE_QUOTA_BYTES = 50 * 1024 ** 3 # Maximum privé, non réservé dans le quota attribué
class QuotaExceeded(Exception):
pass
def parse_quota(value):
"""None = unlimited. Accept only an integer number of bytes (0 is valid)."""
if value is None:
return None
if isinstance(value, bool) or isinstance(value, float):
raise ValueError('Le quota doit être un entier en octets, ou null pour illimité.')
if isinstance(value, str) and (not value.strip().isdigit()):
raise ValueError('Le quota doit être un entier en octets, ou null pour illimité.')
try:
quota = int(value)
except (TypeError, ValueError, OverflowError):
raise ValueError('Quota invalide.') from None
if quota < 0 or quota > MAX_QUOTA_BYTES:
raise ValueError('Quota hors limites (0 à 9 Po environ).')
return quota
def ancestry(space):
result = []
seen = set()
current = space
while current is not None:
if current.pk in seen:
raise ValueError('Hiérarchie des espaces invalide.')
seen.add(current.pk)
result.append(current)
current = current.parent
return result
def subtree_space_ids(space):
"""Resolve the logical quota subtree, not the filesystem path.
Private accounts store files below storage/privates/ but are children of
their assigned spaces *for quota accounting*. Physical path-prefix
queries would silently omit their real disk usage.
"""
by_parent = {}
for pk, parent_id in DriveSpace.objects.values_list('pk', 'parent_id'):
by_parent.setdefault(parent_id, []).append(pk)
result = set()
to_visit = [space.pk]
while to_visit:
pk = to_visit.pop()
if pk in result:
raise ValueError('Hiérarchie des espaces invalide.')
result.add(pk)
to_visit.extend(by_parent.get(pk, []))
return result
def occupancy(space):
"""Count ALL files, even in the trash. Pending sessions reserve their full size."""
ids = subtree_space_ids(space)
files = DriveItem.objects.filter(space_id__in=ids, is_folder=False).aggregate(n=Sum('size'))['n'] or 0
pending = UploadSession.objects.filter(space_id__in=ids).aggregate(n=Sum('total_size'))['n'] or 0
return int(files), int(pending)
def trash_occupancy(space):
"""Bytes still on disk in the trash, including descendants of trashed folders.
Ordinary (not trashed) folders require no traversal. Only traverse the
descendants of explicitly trashed folders; avoid counting trashed files twice.
"""
ids = subtree_space_ids(space)
bytes_in_trash = DriveItem.objects.filter(
space_id__in=ids, is_folder=False, trashed_at__isnull=False
).aggregate(n=Sum('size'))['n'] or 0
folder_ids = list(DriveItem.objects.filter(
space_id__in=ids, is_folder=True, trashed_at__isnull=False
).values_list('pk', flat=True))
visited = set(folder_ids)
while folder_ids:
next_folders = []
for start in range(0, len(folder_ids), 300):
for pk, is_folder, size, trashed in DriveItem.objects.filter(
space_id__in=ids, parent_id__in=folder_ids[start:start + 300]
).values_list('pk', 'is_folder', 'size', 'trashed_at'):
if is_folder:
if pk not in visited:
visited.add(pk)
next_folders.append(pk)
elif trashed is None:
bytes_in_trash += size
folder_ids = next_folders
return int(bytes_in_trash)
def available_for_growth(space):
"""Unreserved quotas: every upload consumes capacity in its space and ALL ancestors."""
limits = []
for node in ancestry(space):
if node.quota_bytes is not None:
used, pending = occupancy(node)
limits.append(node.quota_bytes - used - pending)
return max(0, min(limits)) if limits else None
def assert_new_child_quota(parent, quota):
"""A child limit may not exceed its parent's maximum, but reserves nothing."""
if quota is None and parent.quota_bytes is not None:
raise ValueError('Le quota du sous-espace doit respecter la limite de son parent.')
if quota is not None:
limits = [node.quota_bytes for node in ancestry(parent) if node.quota_bytes is not None]
if limits and quota > min(limits):
raise ValueError('Le quota du sous-espace dépasse la limite de son parent.')
def assert_updated_quota(space, quota):
"""Validate actual occupancy, nested maximums, never preallocate capacity."""
used, pending = occupancy(space)
if quota is not None and quota < used + pending:
raise ValueError('Le quota ne peut pas être inférieur au stockage utilisé.')
if space.parent_id:
assert_new_child_quota(space.parent, quota)
if quota is not None:
# Keep the hierarchy valid if the parent limit shrinks.
descendants = subtree_space_ids(space) - {space.pk}
existing = DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True, quota_bytes__isnull=False)
if existing.filter(quota_bytes__gt=quota).exists():
raise ValueError('Le quota est inférieur à la limite d’un sous-espace existant.')
if quota == 0 and DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True, quota_bytes__isnull=True).exists():
raise ValueError('Un sous-espace sans limite empêche ce changement.')
def quota_state(space):
used, reserved = occupancy(space)
trashed = trash_occupancy(space)
available = available_for_growth(space)
# This number indicates effective capacity already unavailable within a
# child cap, including bytes used elsewhere in a shared parent.
effective_used = used
if not space.private_owner_id and space.quota_bytes is not None and available is not None:
effective_used = max(used + reserved, space.quota_bytes - available)
return {
'quota_bytes': space.quota_bytes,
'used_bytes': max(0, effective_used - reserved),
'actual_used_bytes': used,
'active_bytes': max(0, used - trashed),
'trashed_bytes': trashed,
'reserved_bytes': reserved,
'available_bytes': available,
'effective_limit_bytes': None if available is None else effective_used + available,
}
@contextmanager
def locked_quota_tree(space):
"""Lock ancestors in deterministic order so uploads and limit changes serialize."""
if not transaction.get_connection().in_atomic_block:
raise RuntimeError('locked_quota_tree must run inside transaction.atomic()')
ids = sorted(a.pk for a in ancestry(space))
list(DriveSpace.objects.select_for_update().filter(pk__in=ids).order_by('pk'))
yield
def assert_space_capacity(space, additional_bytes):
"""Called with ancestor locks; private uploads count toward both caps."""
available=available_for_growth(space)
if available is not None and additional_bytes>available:
raise QuotaExceeded(
f'Espace insuffisant : {available} octets encore disponibles après les quotas des sous-espaces.'
)