Optimize empty directory cleanup for recordings (#21695)

The previous empty directory cleanup did a full recursive directory
walk, which can be extremely slow. This new implementation only removes
directories which have a chance of being empty due to a recent file
deletion.
This commit is contained in:
John Shaw 2026-01-17 16:47:21 -06:00 committed by GitHub
parent 38a630af57
commit 1cc50f68a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 49 additions and 24 deletions

View File

@ -11,7 +11,7 @@ from pathlib import Path
from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum
from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE
from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus
from frigate.util.builtin import clear_and_unlink from frigate.util.builtin import clear_and_unlink
from frigate.util.media import remove_empty_directories from frigate.util.media import remove_empty_directories
@ -60,7 +60,7 @@ class RecordingCleanup(threading.Thread):
db.execute_sql("PRAGMA wal_checkpoint(TRUNCATE);") db.execute_sql("PRAGMA wal_checkpoint(TRUNCATE);")
db.close() db.close()
def expire_review_segments(self, config: CameraConfig, now: datetime) -> None: def expire_review_segments(self, config: CameraConfig, now: datetime) -> set[Path]:
"""Delete review segments that are expired""" """Delete review segments that are expired"""
alert_expire_date = ( alert_expire_date = (
now - datetime.timedelta(days=config.record.alerts.retain.days) now - datetime.timedelta(days=config.record.alerts.retain.days)
@ -84,9 +84,12 @@ class RecordingCleanup(threading.Thread):
.namedtuples() .namedtuples()
) )
maybe_empty_dirs = set()
thumbs_to_delete = list(map(lambda x: x[1], expired_reviews)) thumbs_to_delete = list(map(lambda x: x[1], expired_reviews))
for thumb_path in thumbs_to_delete: for thumb_path in thumbs_to_delete:
Path(thumb_path).unlink(missing_ok=True) thumb_path = Path(thumb_path)
thumb_path.unlink(missing_ok=True)
maybe_empty_dirs.add(thumb_path.parent)
max_deletes = 100000 max_deletes = 100000
deleted_reviews_list = list(map(lambda x: x[0], expired_reviews)) deleted_reviews_list = list(map(lambda x: x[0], expired_reviews))
@ -99,13 +102,15 @@ class RecordingCleanup(threading.Thread):
<< deleted_reviews_list[i : i + max_deletes] << deleted_reviews_list[i : i + max_deletes]
).execute() ).execute()
return maybe_empty_dirs
def expire_existing_camera_recordings( def expire_existing_camera_recordings(
self, self,
continuous_expire_date: float, continuous_expire_date: float,
motion_expire_date: float, motion_expire_date: float,
config: CameraConfig, config: CameraConfig,
reviews: ReviewSegment, reviews: ReviewSegment,
) -> None: ) -> set[Path]:
"""Delete recordings for existing camera based on retention config.""" """Delete recordings for existing camera based on retention config."""
# Get the timestamp for cutoff of retained days # Get the timestamp for cutoff of retained days
@ -134,6 +139,8 @@ class RecordingCleanup(threading.Thread):
.iterator() .iterator()
) )
maybe_empty_dirs = set()
# loop over recordings and see if they overlap with any non-expired reviews # loop over recordings and see if they overlap with any non-expired reviews
# TODO: expire segments based on segment stats according to config # TODO: expire segments based on segment stats according to config
review_start = 0 review_start = 0
@ -187,8 +194,10 @@ class RecordingCleanup(threading.Thread):
) )
or (mode == RetainModeEnum.active_objects and recording.objects == 0) or (mode == RetainModeEnum.active_objects and recording.objects == 0)
): ):
Path(recording.path).unlink(missing_ok=True) recording_path = Path(recording.path)
recording_path.unlink(missing_ok=True)
deleted_recordings.add(recording.id) deleted_recordings.add(recording.id)
maybe_empty_dirs.add(recording_path.parent)
else: else:
kept_recordings.append((recording.start_time, recording.end_time)) kept_recordings.append((recording.start_time, recording.end_time))
@ -249,8 +258,10 @@ class RecordingCleanup(threading.Thread):
# Delete previews without any relevant recordings # Delete previews without any relevant recordings
if not keep: if not keep:
Path(preview.path).unlink(missing_ok=True) preview_path = Path(preview.path)
preview_path.unlink(missing_ok=True)
deleted_previews.add(preview.id) deleted_previews.add(preview.id)
maybe_empty_dirs.add(preview_path.parent)
# expire previews # expire previews
logger.debug(f"Expiring {len(deleted_previews)} previews") logger.debug(f"Expiring {len(deleted_previews)} previews")
@ -262,7 +273,9 @@ class RecordingCleanup(threading.Thread):
Previews.id << deleted_previews_list[i : i + max_deletes] Previews.id << deleted_previews_list[i : i + max_deletes]
).execute() ).execute()
def expire_recordings(self) -> None: return maybe_empty_dirs
def expire_recordings(self) -> set[Path]:
"""Delete recordings based on retention config.""" """Delete recordings based on retention config."""
logger.debug("Start expire recordings.") logger.debug("Start expire recordings.")
logger.debug("Start deleted cameras.") logger.debug("Start deleted cameras.")
@ -287,10 +300,14 @@ class RecordingCleanup(threading.Thread):
.iterator() .iterator()
) )
maybe_empty_dirs = set()
deleted_recordings = set() deleted_recordings = set()
for recording in no_camera_recordings: for recording in no_camera_recordings:
Path(recording.path).unlink(missing_ok=True) recording_path = Path(recording.path)
recording_path.unlink(missing_ok=True)
deleted_recordings.add(recording.id) deleted_recordings.add(recording.id)
maybe_empty_dirs.add(recording_path.parent)
logger.debug(f"Expiring {len(deleted_recordings)} recordings") logger.debug(f"Expiring {len(deleted_recordings)} recordings")
# delete up to 100,000 at a time # delete up to 100,000 at a time
@ -307,7 +324,7 @@ class RecordingCleanup(threading.Thread):
logger.debug(f"Start camera: {camera}.") logger.debug(f"Start camera: {camera}.")
now = datetime.datetime.now() now = datetime.datetime.now()
self.expire_review_segments(config, now) maybe_empty_dirs |= self.expire_review_segments(config, now)
continuous_expire_date = ( continuous_expire_date = (
now - datetime.timedelta(days=config.record.continuous.days) now - datetime.timedelta(days=config.record.continuous.days)
).timestamp() ).timestamp()
@ -337,7 +354,7 @@ class RecordingCleanup(threading.Thread):
.namedtuples() .namedtuples()
) )
self.expire_existing_camera_recordings( maybe_empty_dirs |= self.expire_existing_camera_recordings(
continuous_expire_date, motion_expire_date, config, reviews continuous_expire_date, motion_expire_date, config, reviews
) )
logger.debug(f"End camera: {camera}.") logger.debug(f"End camera: {camera}.")
@ -345,6 +362,8 @@ class RecordingCleanup(threading.Thread):
logger.debug("End all cameras.") logger.debug("End all cameras.")
logger.debug("End expire recordings.") logger.debug("End expire recordings.")
return maybe_empty_dirs
def run(self) -> None: def run(self) -> None:
# Expire tmp clips every minute, recordings and clean directories every hour. # Expire tmp clips every minute, recordings and clean directories every hour.
for counter in itertools.cycle(range(self.config.record.expire_interval)): for counter in itertools.cycle(range(self.config.record.expire_interval)):
@ -356,6 +375,6 @@ class RecordingCleanup(threading.Thread):
if counter == 0: if counter == 0:
self.clean_tmp_clips() self.clean_tmp_clips()
self.expire_recordings() maybe_empty_dirs = self.expire_recordings()
remove_empty_directories(RECORD_DIR) remove_empty_directories(maybe_empty_dirs)
self.truncate_wal() self.truncate_wal()

View File

@ -1,9 +1,12 @@
"""Recordings Utilities.""" """Recordings Utilities."""
import datetime import datetime
import errno
import logging import logging
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable
from peewee import DatabaseError, chunked from peewee import DatabaseError, chunked
@ -47,20 +50,23 @@ class SyncResult:
} }
def remove_empty_directories(directory: str) -> None: def remove_empty_directories(paths: Iterable[Path]) -> None:
# list all directories recursively and sort them by path, """
# longest first Remove directories if they exist and are empty.
paths = sorted( Silently ignores non-existent and non-empty directories.
[x[0] for x in os.walk(directory)], """
key=lambda p: len(str(p)), count = 0
reverse=True,
)
for path in paths: for path in paths:
# don't delete the parent try:
if path == directory: path.rmdir()
except FileNotFoundError:
continue continue
if len(os.listdir(path)) == 0: except OSError as e:
os.rmdir(path) if e.errno == errno.ENOTEMPTY:
continue
raise
count += 1
logger.debug("Removed {count} empty directories")
def sync_recordings( def sync_recordings(