Skip to content

Commit b4d4adb

Browse files
authored
Limit recording retention to available storage (blakeblackshear#3942)
* Add field and migration for segment size * Store the segment size in db * Add comment * Add default * Fix size parsing * Include segment size in recordings endpoint * Start adding storage maintainer * Add storage maintainer and calculate average sizes * Update comment * Store segment and hour avg sizes per camera * Formatting * Keep track of total segment and hour averages * Remove unused files * Cleanup 2 hours of recordings at a time * Formatting * Fix bug * Round segment size * Cleanup some comments * Handle case where segments are not deleted on initial run or is only retained segments * Improve cleanup log * Formatting * Fix typo and improve logging * Catch case where no recordings exist for camera * Specifically define sort * Handle edge case for cameras that only record part time * Increase definition of part time recorder * Remove warning about not supported storage based retention * Add note about storage based retention to recording docs * Add tests for storage maintenance calculation and cleanup * Format tests * Don't run for a camera with no recording segments * Get size of file from cache * Rework camera stats to be more efficient * Remove total and other inefficencies * Rewrite storage cleanup logic to be much more efficient * Fix existing tests * Fix bugs from tests * Add another test * Improve logging * Formatting * Set back correct loop time * Update name * Update comment * Only include segments that have a nonzero size * Catch case where camera has 0 nonzero segment durations * Add test to cover zero bandwidth migration case * Fix test * Incorrect boolean logic * Formatting * Explicity re-define iterator
1 parent 3c01dbe commit b4d4adb

File tree

9 files changed

+485
-21
lines changed

9 files changed

+485
-21
lines changed

docs/docs/configuration/record.md

+10-6
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ Recordings can be enabled and are stored at `/media/frigate/recordings`. The fol
77

88
H265 recordings can be viewed in Edge and Safari only. All other browsers require recordings to be encoded with H264.
99

10+
## Will Frigate delete old recordings if my storage runs out?
11+
12+
As of Frigate 0.12 if there is less than an hour left of storage, the oldest 2 hours of recordings will be deleted.
13+
1014
## What if I don't want 24/7 recordings?
1115

1216
If you only used clips in previous versions with recordings disabled, you can use the following config to get the same behavior. This is also the default behavior when recordings are enabled.
@@ -25,23 +29,23 @@ When `retain -> days` is set to `0`, segments will be deleted from the cache if
2529

2630
## Can I have "24/7" recordings, but only at certain times?
2731

28-
Using Frigate UI, HomeAssistant, or MQTT, cameras can be automated to only record in certain situations or at certain times.
32+
Using Frigate UI, HomeAssistant, or MQTT, cameras can be automated to only record in certain situations or at certain times.
2933

3034
**WARNING**: Recordings still must be enabled in the config. If a camera has recordings disabled in the config, enabling via the methods listed above will have no effect.
3135

3236
## What do the different retain modes mean?
3337

34-
Frigate saves from the stream with the `record` role in 10 second segments. These options determine which recording segments are kept for 24/7 recording (but can also affect events).
38+
Frigate saves from the stream with the `record` role in 10 second segments. These options determine which recording segments are kept for 24/7 recording (but can also affect events).
3539

36-
Let's say you have frigate configured so that your doorbell camera would retain the last **2** days of 24/7 recording.
37-
- With the `all` option all 48 hours of those two days would be kept and viewable.
40+
Let's say you have frigate configured so that your doorbell camera would retain the last **2** days of 24/7 recording.
41+
- With the `all` option all 48 hours of those two days would be kept and viewable.
3842
- With the `motion` option the only parts of those 48 hours would be segments that frigate detected motion. This is the middle ground option that won't keep all 48 hours, but will likely keep all segments of interest along with the potential for some extra segments.
3943
- With the `active_objects` option the only segments that would be kept are those where there was a true positive object that was not considered stationary.
4044

4145
The same options are available with events. Let's consider a scenario where you drive up and park in your driveway, go inside, then come back out 4 hours later.
4246
- With the `all` option all segments for the duration of the event would be saved for the event. This event would have 4 hours of footage.
43-
- With the `motion` option all segments for the duration of the event with motion would be saved. This means any segment where a car drove by in the street, person walked by, lighting changed, etc. would be saved.
44-
- With the `active_objects` it would only keep segments where the object was active. In this case the only segments that would be saved would be the ones where the car was driving up, you going inside, you coming outside, and the car driving away. Essentially reducing the 4 hours to a minute or two of event footage.
47+
- With the `motion` option all segments for the duration of the event with motion would be saved. This means any segment where a car drove by in the street, person walked by, lighting changed, etc. would be saved.
48+
- With the `active_objects` it would only keep segments where the object was active. In this case the only segments that would be saved would be the ones where the car was driving up, you going inside, you coming outside, and the car driving away. Essentially reducing the 4 hours to a minute or two of event footage.
4549

4650
A configuration example of the above retain modes where all `motion` segments are stored for 7 days and `active objects` are stored for 14 days would be as follows:
4751
```yaml

docs/docs/installation.md

-6
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,6 @@ Windows is not officially supported, but some users have had success getting it
2121

2222
Frigate uses the following locations for read/write operations in the container. Docker volume mappings can be used to map these to any location on your host machine.
2323

24-
:::caution
25-
26-
Note that Frigate does not currently support limiting recordings based on available disk space automatically. If using recordings, you must specify retention settings for a number of days that will fit within the available disk space of your drive or Frigate will crash.
27-
28-
:::
29-
3024
- `/media/frigate/clips`: Used for snapshot storage. In the future, it will likely be renamed from `clips` to `snapshots`. The file structure here cannot be modified and isn't intended to be browsed or managed manually.
3125
- `/media/frigate/recordings`: Internal system storage for recording segments. The file structure here cannot be modified and isn't intended to be browsed or managed manually.
3226
- `/media/frigate/frigate.db`: Default location for the sqlite database. You will also see several files alongside this file while frigate is running. If moving the database location (often needed when using a network drive at `/media/frigate`), it is recommended to mount a volume with docker at `/db` and change the storage location of the database to `/db/frigate.db` in the config file.

frigate/app.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,17 @@
1-
import json
21
import logging
32
import multiprocessing as mp
43
from multiprocessing.queues import Queue
54
from multiprocessing.synchronize import Event
6-
from multiprocessing.context import Process
75
import os
86
import signal
97
import sys
10-
import threading
11-
from logging.handlers import QueueHandler
128
from typing import Optional
139
from types import FrameType
1410

1511
import traceback
16-
import yaml
1712
from peewee_migrate import Router
1813
from playhouse.sqlite_ext import SqliteExtDatabase
1914
from playhouse.sqliteq import SqliteQueueDatabase
20-
from pydantic import ValidationError
2115

2216
from frigate.config import DetectorTypeEnum, FrigateConfig
2317
from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR
@@ -32,6 +26,7 @@
3226
from frigate.plus import PlusApi
3327
from frigate.record import RecordingCleanup, RecordingMaintainer
3428
from frigate.stats import StatsEmitter, stats_init
29+
from frigate.storage import StorageMaintainer
3530
from frigate.version import VERSION
3631
from frigate.video import capture_camera, track_camera
3732
from frigate.watchdog import FrigateWatchdog
@@ -310,6 +305,10 @@ def start_recording_cleanup(self) -> None:
310305
self.recording_cleanup = RecordingCleanup(self.config, self.stop_event)
311306
self.recording_cleanup.start()
312307

308+
def start_storage_maintainer(self) -> None:
309+
self.storage_maintainer = StorageMaintainer(self.config, self.stop_event)
310+
self.storage_maintainer.start()
311+
313312
def start_stats_emitter(self) -> None:
314313
self.stats_emitter = StatsEmitter(
315314
self.config,
@@ -369,6 +368,7 @@ def start(self) -> None:
369368
self.start_event_cleanup()
370369
self.start_recording_maintainer()
371370
self.start_recording_cleanup()
371+
self.start_storage_maintainer()
372372
self.start_stats_emitter()
373373
self.start_watchdog()
374374
# self.zeroconf = broadcast_zeroconf(self.config.mqtt.client_id)

frigate/http.py

+1
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,7 @@ def recordings(camera_name):
751751
Recordings.id,
752752
Recordings.start_time,
753753
Recordings.end_time,
754+
Recordings.segment_size,
754755
Recordings.motion,
755756
Recordings.objects,
756757
)

frigate/models.py

+1
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,4 @@ class Recordings(Model): # type: ignore[misc]
4141
duration = FloatField()
4242
motion = IntegerField(null=True)
4343
objects = IntegerField(null=True)
44+
segment_size = FloatField(default=0) # this should be stored as MB

frigate/record.py

+10-3
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,15 @@ def store_segment(
284284
f"Copied {file_path} in {datetime.datetime.now().timestamp()-start_frame} seconds."
285285
)
286286

287+
try:
288+
segment_size = round(
289+
float(os.path.getsize(cache_path)) / 1000000, 1
290+
)
291+
except OSError:
292+
segment_size = 0
293+
294+
os.remove(cache_path)
295+
287296
rand_id = "".join(
288297
random.choices(string.ascii_lowercase + string.digits, k=6)
289298
)
@@ -297,10 +306,8 @@ def store_segment(
297306
motion=motion_count,
298307
# TODO: update this to store list of active objects at some point
299308
objects=active_count,
309+
segment_size=segment_size,
300310
)
301-
else:
302-
logger.warning(f"Ignoring segment because {file_path} already exists.")
303-
os.remove(cache_path)
304311
except Exception as e:
305312
logger.error(f"Unable to store recording segment {cache_path}")
306313
Path(cache_path).unlink(missing_ok=True)

frigate/storage.py

+172
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Handle storage retention and usage."""
2+
3+
import logging
4+
from pathlib import Path
5+
import shutil
6+
import threading
7+
8+
from peewee import fn
9+
10+
from frigate.config import FrigateConfig
11+
from frigate.const import RECORD_DIR
12+
from frigate.models import Event, Recordings
13+
14+
logger = logging.getLogger(__name__)
15+
bandwidth_equation = Recordings.segment_size / (
16+
Recordings.end_time - Recordings.start_time
17+
)
18+
19+
20+
class StorageMaintainer(threading.Thread):
21+
"""Maintain frigates recording storage."""
22+
23+
def __init__(self, config: FrigateConfig, stop_event) -> None:
24+
threading.Thread.__init__(self)
25+
self.name = "storage_maintainer"
26+
self.config = config
27+
self.stop_event = stop_event
28+
self.camera_storage_stats: dict[str, dict] = {}
29+
30+
def calculate_camera_bandwidth(self) -> None:
31+
"""Calculate an average MB/hr for each camera."""
32+
for camera in self.config.cameras.keys():
33+
# cameras with < 50 segments should be refreshed to keep size accurate
34+
# when few segments are available
35+
if self.camera_storage_stats.get(camera, {}).get("needs_refresh", True):
36+
self.camera_storage_stats[camera] = {
37+
"needs_refresh": (
38+
Recordings.select(fn.COUNT(Recordings.id))
39+
.where(
40+
Recordings.camera == camera, Recordings.segment_size != 0
41+
)
42+
.scalar()
43+
< 50
44+
)
45+
}
46+
47+
# calculate MB/hr
48+
try:
49+
bandwidth = round(
50+
Recordings.select(fn.AVG(bandwidth_equation))
51+
.where(Recordings.camera == camera, Recordings.segment_size != 0)
52+
.limit(100)
53+
.scalar()
54+
* 3600,
55+
2,
56+
)
57+
except TypeError:
58+
bandwidth = 0
59+
60+
self.camera_storage_stats[camera]["bandwidth"] = bandwidth
61+
logger.debug(f"{camera} has a bandwidth of {bandwidth} MB/hr.")
62+
63+
def check_storage_needs_cleanup(self) -> bool:
64+
"""Return if storage needs cleanup."""
65+
# currently runs cleanup if less than 1 hour of space is left
66+
# disk_usage should not spin up disks
67+
hourly_bandwidth = sum(
68+
[b["bandwidth"] for b in self.camera_storage_stats.values()]
69+
)
70+
remaining_storage = round(shutil.disk_usage(RECORD_DIR).free / 1000000, 1)
71+
logger.debug(
72+
f"Storage cleanup check: {hourly_bandwidth} hourly with remaining storage: {remaining_storage}."
73+
)
74+
return remaining_storage < hourly_bandwidth
75+
76+
def reduce_storage_consumption(self) -> None:
77+
"""Remove oldest hour of recordings."""
78+
logger.debug("Starting storage cleanup.")
79+
deleted_segments_size = 0
80+
hourly_bandwidth = sum(
81+
[b["bandwidth"] for b in self.camera_storage_stats.values()]
82+
)
83+
84+
recordings: Recordings = Recordings.select().order_by(
85+
Recordings.start_time.asc()
86+
)
87+
retained_events: Event = (
88+
Event.select()
89+
.where(
90+
Event.retain_indefinitely == True,
91+
Event.has_clip,
92+
)
93+
.order_by(Event.start_time.asc())
94+
.objects()
95+
)
96+
97+
event_start = 0
98+
deleted_recordings = set()
99+
for recording in recordings.objects().iterator():
100+
# check if 1 hour of storage has been reclaimed
101+
if deleted_segments_size > hourly_bandwidth:
102+
break
103+
104+
keep = False
105+
106+
# Now look for a reason to keep this recording segment
107+
for idx in range(event_start, len(retained_events)):
108+
event = retained_events[idx]
109+
110+
# if the event starts in the future, stop checking events
111+
# and let this recording segment expire
112+
if event.start_time > recording.end_time:
113+
keep = False
114+
break
115+
116+
# if the event is in progress or ends after the recording starts, keep it
117+
# and stop looking at events
118+
if event.end_time is None or event.end_time >= recording.start_time:
119+
keep = True
120+
break
121+
122+
# if the event ends before this recording segment starts, skip
123+
# this event and check the next event for an overlap.
124+
# since the events and recordings are sorted, we can skip events
125+
# that end before the previous recording segment started on future segments
126+
if event.end_time < recording.start_time:
127+
event_start = idx
128+
129+
# Delete recordings not retained indefinitely
130+
if not keep:
131+
deleted_segments_size += recording.segment_size
132+
Path(recording.path).unlink(missing_ok=True)
133+
deleted_recordings.add(recording.id)
134+
135+
# check if need to delete retained segments
136+
if deleted_segments_size < hourly_bandwidth:
137+
logger.error(
138+
f"Could not clear {hourly_bandwidth} currently {deleted_segments_size}, retained recordings must be deleted."
139+
)
140+
recordings = Recordings.select().order_by(Recordings.start_time.asc())
141+
142+
for recording in recordings.objects().iterator():
143+
if deleted_segments_size > hourly_bandwidth:
144+
break
145+
146+
deleted_segments_size += recording.segment_size
147+
Path(recording.path).unlink(missing_ok=True)
148+
deleted_recordings.add(recording.id)
149+
150+
logger.debug(f"Expiring {len(deleted_recordings)} recordings")
151+
# delete up to 100,000 at a time
152+
max_deletes = 100000
153+
deleted_recordings_list = list(deleted_recordings)
154+
for i in range(0, len(deleted_recordings_list), max_deletes):
155+
Recordings.delete().where(
156+
Recordings.id << deleted_recordings_list[i : i + max_deletes]
157+
).execute()
158+
159+
def run(self):
160+
"""Check every 5 minutes if storage needs to be cleaned up."""
161+
while not self.stop_event.wait(300):
162+
163+
if not self.camera_storage_stats or True in [
164+
r["needs_refresh"] for r in self.camera_storage_stats.values()
165+
]:
166+
self.calculate_camera_bandwidth()
167+
logger.debug(f"Default camera bandwidths: {self.camera_storage_stats}.")
168+
169+
if self.check_storage_needs_cleanup():
170+
self.reduce_storage_consumption()
171+
172+
logger.info(f"Exiting storage maintainer...")

0 commit comments

Comments
 (0)