Compare commits

...
4 Commits
Author SHA1 Message Date
DanielandGitHub 67d8b4cd78 Merge d80d020c86 into f065cc8642 2026-06-23 04:28:50 -05:00
f065cc8642 fix unbounded recordings_info growth for cameras with no cache segments (#23528)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
A record-enabled camera whose record stream produces no cache segments
never appears in grouped_recordings, so the per-camera prune in
RecordingMaintainer.move_files() never runs for it. Its
object_recordings_info and audio_recordings_info buffers then grow
without bound until the recording process is OOM-killed (discussion
#23451).

Run a prune every move_files() cycle for cameras absent from
grouped_recordings, dropping entries older than the longest a segment
could still wait in cache before being matched
(MAX_SEGMENTS_IN_CACHE * MAX_SEGMENT_DURATION * 2). Cameras present in
grouped_recordings are left untouched and keep their existing prune.

Add a regression test asserting that an absent camera's stale entries
are dropped (recent ones kept) while a present camera's entries are
left intact.

Co-authored-by: John Pescatore <johnpescatore@claude.internal.johnpescatore.com>
2026-06-22 14:33:56 -06:00
Josh HawkinsandGitHub 9ce80e7266 Improve storage docs (#23542)
* improve storage docs

* clarify

* tweak language

* move section
2026-06-22 15:27:24 -05:00
Daniel-dev22andClaude Opus 4.8 d80d020c86 perf(track): avoid numpy reductions on tiny box lists in position smoothing
update_position runs per tracked object per frame. While a position has
fewer than 10 samples it calls np.percentile four times, and average_boxes
(per stationary object per frame) calls np.mean four times - all on lists of
at most 10 ints, where numpy's per-call dispatch/validation overhead
dominates the actual work.

Replace them with pure-Python equivalents:
- average_boxes: sum()/len() instead of np.mean (bit-identical output)
- interpolated_percentile(): linear-interpolated percentile matching
  numpy.percentile (including its lerp branch at frac>=0.5) for the small
  lists used here, in place of np.percentile

Measured in the release image (numpy 1.26.4) on a 10-element list:
np.percentile 18735 ns -> 191 ns/call (98x); np.mean-based average_boxes
7480 ns -> 591 ns (12.7x); ~74 us saved per object-frame in update_position.
A live py-spy --gil profile of a camera process_frames worker showed
np.percentile (update_position) and np.mean (average_boxes) among the top
Frigate-owned on-CPU frames.

Output is unchanged: added tests assert both helpers are bit-identical to
numpy over randomized small inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:25:04 -04:00
8 changed files with 187 additions and 21 deletions
+2
View File
@@ -59,6 +59,8 @@ Metrics are available at `/api/metrics` by default. No additional Frigate config
- `frigate_storage_used_bytes{storage=""}` - Storage used bytes
- `frigate_storage_mount_type{mount_type="", storage=""}` - Storage mount type info
These gauges report the operating system's figures for the whole filesystem (the same numbers as `df`), not Frigate's own recording footprint. For how this differs from the recordings usage shown in the UI, see [Understanding storage usage](/configuration/record#understanding-storage-usage).
### Service Metrics
- `frigate_service_uptime_seconds` - Uptime in seconds
+60 -4
View File
@@ -199,10 +199,6 @@ Because recording segments are written in 10 second chunks, pre-capture timing d
Pre and post capture footage is included in the **recording timeline**, visible in the History view. Note that pre/post capture settings only affect which recording segments are **retained on disk** — they do not change the start and end points shown in the UI. The History view will still center on the review item's actual time range, but you can scrub backward and forward through the retained pre/post capture footage on the timeline. The Explore view shows object-specific clips that are trimmed to when the tracked object was actually visible, so pre/post capture time will not be reflected there.
## Will Frigate delete old recordings if my storage runs out?
If there is less than an hour left of storage, the oldest hour of recordings will be deleted and a message will be printed in the Frigate logs. This emergency cleanup deletes the oldest recordings first regardless of retention settings to reclaim space as quickly as possible.
## Configuring Recording Retention
Frigate supports both continuous and tracked object based recordings with separate retention modes and retention periods.
@@ -355,3 +351,63 @@ Setting `verbose: true` writes a detailed report of every orphaned file and data
This operation uses considerable CPU resources and includes a safety threshold that aborts if more than 50% of files would be deleted. Only run when necessary. If you set `force: true` the safety threshold will be bypassed; do not use `force` unless you are certain the deletions are intended.
:::
## Understanding storage usage
The storage usage Frigate reports will not exactly match what the operating system reports with `df` or `du`. This is expected, not a bug. The sections below explain how Frigate derives its storage figures and why they differ from the disk's own accounting.
### How Frigate measures recording usage
The **Recordings** value on the Storage Metrics page (<NavPath path="System > Storage" />) — and the per-camera **Camera Storage** breakdown — is the sum of the recording segment sizes Frigate has written, taken from Frigate's database. It is **not** computed by a scan of the disk. Frigate tracks usage this way by design: repeatedly walking the entire drive to total its size would keep hard drives spun up and add unnecessary I/O.
The disk **total** shown beside it, and the free-space figure Frigate uses to decide when to delete recordings, instead come from the operating system's report for the whole filesystem mounted at `/media/frigate`. As a result, the **Unused** value on the page is _total disk capacity minus Frigate's recordings_ — not the drive's real free space, which will be lower whenever anything else is stored on the disk.
### What counts toward usage — and why it won't match `df`
Only **recording segments** (`/media/frigate/recordings`) are included in the recordings storage total. Plenty of other things consume real disk space but are **not** part of that number:
- **Snapshots and thumbnails** (`/media/frigate/clips`) — see [Snapshots](/configuration/snapshots). These are retained independently of recordings.
- **Preview videos** and **review thumbnails** (also under `/media/frigate/clips`).
- **Exports** (`/media/frigate/exports`) — exports are never removed by retention.
- **The database, downloaded detection models, and face / license plate training images** (stored under `/config`).
- **Debug images from enrichments** (`/media/frigate/clips`) — when enabled, License Plate Recognition's `debug_save_plates` and GenAI's `debug_save_thumbnails` save plate crops and request images for troubleshooting.
These files are the usual explanation for an "other" or seemingly unaccounted bucket of space — it is real, it is Frigate's, and it simply isn't part of the _recordings_ total. They are also why comparing the **Recordings** figure to `df -h` always shows a gap: `df` additionally counts any non-Frigate data on the disk, filesystem overhead and reserved blocks (ext4 reserves ~5% for root by default, so a disk can read "full" before recordings approach the total), and recently deleted recordings whose space has not yet been reclaimed.
:::tip
The Storage page is not intended to be a system-wide disk monitor — it shows how much space _Frigate's recordings_ use. To see true disk usage, use `df -h` (free space) and `du -sh` (per-directory usage) on the host.
:::
### Free space and the `/media/frigate` mount
Frigate reports the capacity and free space of whatever filesystem is actually mounted at `/media/frigate` **inside the container**. If an external drive or network share isn't truly mounted there — a missing `/etc/fstab` entry, a share that was offline when the container started, or a host that doesn't pass the path through — the container falls back to the host's OS disk, and Frigate will correctly report that smaller disk instead of the drive you intended.
If the reported capacity doesn't match your drive, the mount is the place to look, not Frigate. Verify what is actually mounted from inside the container:
```bash
docker exec -it frigate df -h /media/frigate
docker exec -it frigate mount | grep media
```
See the [storage mount layout](/frigate/installation#storage) for how the volumes are expected to be configured.
### The `/tmp/cache` area is separate
Recording segments are first written to `/tmp/cache` — a small, in-memory (`tmpfs`) area — before being checked and moved to `/media/frigate/recordings`. Because it is separate and small, `/tmp/cache` can fill up and produce `No space left on device` errors even when the recordings disk has plenty of room — they are different storage areas. See [Recordings troubleshooting](/troubleshooting/recordings) for diagnosing cache and slow-storage issues.
### When the metrics don't match what's on disk
Because usage is tracked in the database, deleting recording files directly on disk — or files left behind after an upgrade — will not update the reported usage, and can even push it above 100%. Frigate is unaware of files it didn't record and won't count or remove them automatically. Use [Syncing Media Files With Disk](#syncing-media-files-with-disk) to reconcile the database with what is actually on disk.
## Will Frigate delete old recordings if my storage runs out?
Yes. Frigate continuously checks the **free space of the disk** holding `/media/frigate/recordings`. This is different from adding up the size of every recording: free space is a single number the operating system already tracks, so Frigate can ask for it instantly without reading through your files or spinning up the disk — which is exactly why it relies on this check rather than scanning the drive. When less than roughly one hour of recording space remains — estimated from the current recording bitrate, **not** a fixed percentage — Frigate deletes the oldest recordings to reclaim space and logs a message. This emergency cleanup removes the oldest recordings first **regardless of retention settings**.
Two consequences follow from this being based on whole-disk free space:
- Because the check uses the disk's real free space, **anything** filling the drive — including non-Frigate files — can trigger deletion of your oldest recordings.
- Cleanup can run while a meaningful percentage of the disk is still free (for example, with high bitrates or many cameras), because the threshold is "less than ~1 hour of recording headroom," not "X% full."
Frequent emergency cleanups usually mean your configured retention exceeds what the disk can hold. Reduce your retention days so the normal retention cleanup keeps up and the emergency path rarely triggers.
+2
View File
@@ -42,6 +42,8 @@ Frigate requires a CPU with AVX + AVX2 instructions. Most modern CPUs (post-2011
Storage is an important consideration when planning a new installation. To get a more precise estimate of your storage requirements, you can use an IP camera storage calculator. Websites like [IPConfigure Storage Calculator](https://calculator.ipconfigure.com/) can help you determine the necessary disk space based on your camera settings.
Once running, see [Understanding storage usage](/configuration/record#understanding-storage-usage) for how Frigate measures and reports disk usage — and why its numbers won't exactly match `df` or `du`.
#### SSDs (Solid State Drives)
SSDs are an excellent choice for Frigate, offering high speed and responsiveness. The older concern that SSDs would quickly "wear out" from constant video recording is largely no longer valid for modern consumer and enterprise-grade SSDs.
+19
View File
@@ -42,6 +42,8 @@ from frigate.util.services import get_video_properties
logger = logging.getLogger(__name__)
STALE_RECORDINGS_INFO_TTL = MAX_SEGMENTS_IN_CACHE * MAX_SEGMENT_DURATION * 2
class SegmentInfo:
def __init__(
@@ -301,6 +303,8 @@ class RecordingMaintainer(threading.Thread):
RecordingsDataTypeEnum.saved.value,
)
self._expire_stale_recordings_info(grouped_recordings)
recordings_to_insert: list[Optional[dict[str, Any]]] = await asyncio.gather(
*tasks
)
@@ -311,6 +315,21 @@ class RecordingMaintainer(threading.Thread):
[r for r in recordings_to_insert if r is not None],
)
def _expire_stale_recordings_info(
self, grouped_recordings: defaultdict[str, list[dict[str, Any]]]
) -> None:
expire_before = datetime.datetime.now().timestamp() - STALE_RECORDINGS_INFO_TTL
for recordings_info in (
self.object_recordings_info,
self.audio_recordings_info,
):
for camera in list(recordings_info.keys()):
if camera in grouped_recordings:
continue
info = recordings_info[camera]
while info and info[0][0] < expire_before:
info.pop(0)
def drop_segment(self, cache_path: str) -> None:
Path(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None)
+40
View File
@@ -115,6 +115,46 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(result)
maintainer.drop_segment.assert_called_once_with(cache_path)
async def test_expire_stale_recordings_info_drops_only_absent_cameras(self):
config = MagicMock(spec=FrigateConfig)
config.cameras = {}
stop_event = MagicMock()
maintainer = RecordingMaintainer(config, stop_event)
now = datetime.datetime.now().timestamp()
ancient = now - 86400
recent = now - 1
maintainer.object_recordings_info["present_cam"] = [(ancient, [], [], [])]
maintainer.audio_recordings_info["present_cam"] = [(ancient, 0, [])]
maintainer.object_recordings_info["absent_cam"] = [
(ancient, [], [], []),
(recent, [], [], []),
]
maintainer.audio_recordings_info["absent_cam"] = [
(ancient, 0, []),
(recent, 0, []),
]
grouped_recordings = {"present_cam": [{"start_time": ancient}]}
maintainer._expire_stale_recordings_info(grouped_recordings)
self.assertEqual(
maintainer.object_recordings_info["present_cam"], [(ancient, [], [], [])]
)
self.assertEqual(
maintainer.audio_recordings_info["present_cam"], [(ancient, 0, [])]
)
self.assertEqual(
maintainer.object_recordings_info["absent_cam"], [(recent, [], [], [])]
)
self.assertEqual(
maintainer.audio_recordings_info["absent_cam"], [(recent, 0, [])]
)
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -1,6 +1,35 @@
import random
import unittest
import numpy as np
from frigate.track.tracked_object import TrackedObjectAttribute
from frigate.util.object import average_boxes, interpolated_percentile
class TestBoxStatistics(unittest.TestCase):
def test_average_boxes_matches_numpy(self) -> None:
rng = random.Random(0)
for _ in range(5000):
boxes = [
[rng.randint(0, 4000) for _ in range(4)]
for _ in range(rng.randint(1, 10))
]
expected = [float(np.mean([b[i] for b in boxes])) for i in range(4)]
self.assertEqual(average_boxes(boxes), expected)
def test_interpolated_percentile_matches_numpy(self) -> None:
rng = random.Random(1)
for _ in range(5000):
values = [rng.randint(0, 5000) for _ in range(rng.randint(1, 10))]
for q in (15, 85, 50):
self.assertEqual(
interpolated_percentile(values, q),
float(np.percentile(values, q)),
)
def test_interpolated_percentile_single_value(self) -> None:
self.assertEqual(interpolated_percentile([42], 15), 42.0)
class TestAttribute(unittest.TestCase):
+9 -5
View File
@@ -27,7 +27,11 @@ from frigate.util.image import (
get_histogram,
intersection_over_union,
)
from frigate.util.object import average_boxes, median_of_boxes
from frigate.util.object import (
average_boxes,
interpolated_percentile,
median_of_boxes,
)
logger = logging.getLogger(__name__)
@@ -428,10 +432,10 @@ class NorfairTracker(ObjectTracker):
position["xmaxs"].append(xmax)
position["ymaxs"].append(ymax)
# by using percentiles here, we hopefully remove outliers
position["xmin"] = np.percentile(position["xmins"], 15)
position["ymin"] = np.percentile(position["ymins"], 15)
position["xmax"] = np.percentile(position["xmaxs"], 85)
position["ymax"] = np.percentile(position["ymaxs"], 85)
position["xmin"] = interpolated_percentile(position["xmins"], 15)
position["ymin"] = interpolated_percentile(position["ymins"], 15)
position["xmax"] = interpolated_percentile(position["xmaxs"], 85)
position["ymax"] = interpolated_percentile(position["ymaxs"], 85)
return True
+26 -12
View File
@@ -339,18 +339,13 @@ def reduce_boxes(boxes, iou_threshold=0.0):
def average_boxes(boxes: list[list[int, int, int, int]]) -> list[int, int, int, int]:
"""Return a box that is the average of a list of boxes."""
x_mins = []
y_mins = []
x_max = []
y_max = []
for box in boxes:
x_mins.append(box[0])
y_mins.append(box[1])
x_max.append(box[2])
y_max.append(box[3])
return [np.mean(x_mins), np.mean(y_mins), np.mean(x_max), np.mean(y_max)]
n = len(boxes)
return [
sum(box[0] for box in boxes) / n,
sum(box[1] for box in boxes) / n,
sum(box[2] for box in boxes) / n,
sum(box[3] for box in boxes) / n,
]
def median_of_boxes(boxes: list[list[int, int, int, int]]) -> list[int, int, int, int]:
@@ -359,6 +354,25 @@ def median_of_boxes(boxes: list[list[int, int, int, int]]) -> list[int, int, int
return sorted_boxes[int(len(sorted_boxes) / 2.0)]
def interpolated_percentile(values: list[int], q: float) -> float:
"""Linear-interpolated percentile, matching numpy.percentile for the small
lists used in position smoothing without the per-call numpy overhead."""
ordered = sorted(values)
n = len(ordered)
if n == 1:
return float(ordered[0])
rank = (q / 100.0) * (n - 1)
lo = int(rank)
frac = rank - lo
if frac == 0.0:
return float(ordered[lo])
diff = ordered[lo + 1] - ordered[lo]
# numpy's lerp switches sides at frac >= 0.5 to limit rounding error
if frac < 0.5:
return ordered[lo] + diff * frac
return ordered[lo + 1] - diff * (1.0 - frac)
def intersects_any(box_a, boxes):
for box in boxes:
if box_overlaps(box_a, box):