183 lines
7.5 KiB
Python
183 lines
7.5 KiB
Python
"""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.'
|
||
)
|