Show main and sub stream usage separately in Storage Metrics (#24015)

* backend

* frontend

* docs

* test

* report null instead of 0 for a stream with no cached bandwidth sample
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent ad04101c76
commit 6ba9dd92e4
7 changed files with 397 additions and 103 deletions
+40 -8
View File
@@ -88,7 +88,7 @@ class StorageMaintainer(threading.Thread):
# type and sum the rates; mixing streams would average small
# sub segments against large main segments and underestimate
# the true write rate
bandwidth = 0.0
bandwidth_by_stream: dict[str, float] = {}
for stream_type in (STREAM_TYPE_MAIN, STREAM_TYPE_SUB):
avg_bw = self._recent_stream_bandwidth(camera, stream_type, 100)
if avg_bw is None:
@@ -99,17 +99,27 @@ class StorageMaintainer(threading.Thread):
camera, stream_type, 1000
)
if avg_bw is not None:
bandwidth += round(avg_bw * 3600, 2)
bandwidth_by_stream[stream_type] = round(avg_bw * 3600, 2)
bandwidth = round(bandwidth, 2)
bandwidth = round(sum(bandwidth_by_stream.values()), 2)
if bandwidth > MAX_CALCULATED_BANDWIDTH:
logger.warning(
f"{camera} has a bandwidth of {bandwidth} MB/hr which exceeds the expected maximum. This typically indicates an issue with the cameras recordings."
)
# scale each stream so the per stream values still sum to
# the clamped total the UI displays alongside them
scale = MAX_CALCULATED_BANDWIDTH / bandwidth
bandwidth_by_stream = {
stream_type: round(value * scale, 2)
for stream_type, value in bandwidth_by_stream.items()
}
bandwidth = MAX_CALCULATED_BANDWIDTH
self.camera_storage_stats[camera]["bandwidth"] = bandwidth
self.camera_storage_stats[camera]["bandwidth_by_stream"] = (
bandwidth_by_stream
)
logger.debug(f"{camera} has a bandwidth of {bandwidth} MiB/hr.")
def calculate_camera_usages(self) -> dict[str, dict]:
@@ -121,20 +131,42 @@ class StorageMaintainer(threading.Thread):
if camera.startswith(REPLAY_CAMERA_PREFIX):
continue
camera_storage = (
Recordings.select(fn.SUM(Recordings.segment_size))
.where(Recordings.camera == camera, Recordings.segment_size != 0)
.scalar()
stream_usages = {
row["stream_type"]: row["usage"] or 0
for row in (
Recordings.select(
Recordings.stream_type,
fn.SUM(Recordings.segment_size).alias("usage"),
)
.where(Recordings.camera == camera, Recordings.segment_size != 0)
.group_by(Recordings.stream_type)
.dicts()
)
}
stream_bandwidths = self.camera_storage_stats.get(camera, {}).get(
"bandwidth_by_stream", {}
)
camera_key = (
getattr(self.config.cameras[camera], "friendly_name", None) or camera
)
usages[camera_key] = {
"usage": camera_storage,
"usage": sum(stream_usages.values()),
"bandwidth": self.camera_storage_stats.get(camera, {}).get(
"bandwidth", 0
),
# only streams with segments on disk are reported, so a camera
# keeps its sub entry until sub retention expires those segments.
# bandwidth is null rather than 0 when the cache holds no sample
# for the stream, since 0 would claim it writes nothing
"streams": {
stream_type: {
"usage": stream_usages[stream_type],
"bandwidth": stream_bandwidths.get(stream_type),
}
for stream_type in (STREAM_TYPE_MAIN, STREAM_TYPE_SUB)
if stream_usages.get(stream_type)
},
}
return usages
+170 -5
View File
@@ -11,8 +11,9 @@ from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.config import FrigateConfig
from frigate.const import STREAM_TYPE_MAIN, STREAM_TYPE_SUB
from frigate.models import Event, Recordings
from frigate.storage import StorageMaintainer
from frigate.storage import MAX_CALCULATED_BANDWIDTH, StorageMaintainer
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
@@ -114,8 +115,16 @@ class TestHttp(unittest.TestCase):
)
storage.calculate_camera_bandwidth()
assert storage.camera_storage_stats == {
"front_door": {"bandwidth": 1440, "needs_refresh": True},
"back_door": {"bandwidth": 2880, "needs_refresh": True},
"front_door": {
"bandwidth": 1440,
"bandwidth_by_stream": {STREAM_TYPE_MAIN: 1440},
"needs_refresh": True,
},
"back_door": {
"bandwidth": 2880,
"bandwidth_by_stream": {STREAM_TYPE_MAIN: 2880},
"needs_refresh": True,
},
}
def test_segment_calculations_with_zero_segments(self):
@@ -136,7 +145,11 @@ class TestHttp(unittest.TestCase):
)
storage.calculate_camera_bandwidth()
assert storage.camera_storage_stats == {
"front_door": {"bandwidth": 0, "needs_refresh": True},
"front_door": {
"bandwidth": 0,
"bandwidth_by_stream": {},
"needs_refresh": True,
},
}
def test_segment_calculations_with_recent_zero_segments(self):
@@ -171,9 +184,159 @@ class TestHttp(unittest.TestCase):
storage.calculate_camera_bandwidth()
assert storage.camera_storage_stats == {
"front_door": {"bandwidth": 1440, "needs_refresh": True},
"front_door": {
"bandwidth": 1440,
"bandwidth_by_stream": {STREAM_TYPE_MAIN: 1440},
"needs_refresh": True,
},
}
def test_camera_usages_split_by_stream_type(self):
"""Usage and bandwidth are reported per stream type."""
config = FrigateConfig(**self.minimal_config)
storage = StorageMaintainer(config, MagicMock())
time_keep = datetime.datetime.now().timestamp()
_insert_mock_recording(
"1234567.frontdoor",
os.path.join(self.test_dir, "main.tmp"),
time_keep,
time_keep + 10,
seg_size=20,
seg_dur=10,
stream_type=STREAM_TYPE_MAIN,
)
_insert_mock_recording(
"1234568.frontdoor",
os.path.join(self.test_dir, "sub.tmp"),
time_keep,
time_keep + 10,
seg_size=2,
seg_dur=10,
stream_type=STREAM_TYPE_SUB,
)
storage.calculate_camera_bandwidth()
usages = storage.calculate_camera_usages()
assert usages["front_door"]["usage"] == 22
assert usages["front_door"]["bandwidth"] == 7920
assert usages["front_door"]["streams"] == {
STREAM_TYPE_MAIN: {"usage": 20, "bandwidth": 7200},
STREAM_TYPE_SUB: {"usage": 2, "bandwidth": 720},
}
def test_camera_usages_omits_streams_without_segments(self):
"""A camera with no sub segments reports no sub entry."""
config = FrigateConfig(**self.minimal_config)
storage = StorageMaintainer(config, MagicMock())
time_keep = datetime.datetime.now().timestamp()
_insert_mock_recording(
"1234567.frontdoor",
os.path.join(self.test_dir, "main.tmp"),
time_keep,
time_keep + 10,
seg_size=20,
seg_dur=10,
)
storage.calculate_camera_bandwidth()
usages = storage.calculate_camera_usages()
assert usages["front_door"]["usage"] == 20
assert usages["front_door"]["streams"] == {
STREAM_TYPE_MAIN: {"usage": 20, "bandwidth": 7200},
}
def test_camera_bandwidth_clamp_scales_stream_values(self):
"""Clamping the total keeps the per stream values summing to it."""
config = FrigateConfig(**self.minimal_config)
storage = StorageMaintainer(config, MagicMock())
time_keep = datetime.datetime.now().timestamp()
_insert_mock_recording(
"1234567.frontdoor",
os.path.join(self.test_dir, "main.tmp"),
time_keep,
time_keep + 10,
seg_size=40,
seg_dur=10,
stream_type=STREAM_TYPE_MAIN,
)
_insert_mock_recording(
"1234568.frontdoor",
os.path.join(self.test_dir, "sub.tmp"),
time_keep,
time_keep + 10,
seg_size=4,
seg_dur=10,
stream_type=STREAM_TYPE_SUB,
)
storage.calculate_camera_bandwidth()
stats = storage.camera_storage_stats["front_door"]
assert stats["bandwidth"] == MAX_CALCULATED_BANDWIDTH
assert (
round(sum(stats["bandwidth_by_stream"].values()), 2)
== MAX_CALCULATED_BANDWIDTH
)
def test_stream_bandwidth_is_none_without_a_cached_sample(self):
"""A stream that appears after the bandwidth cache freezes has no estimate.
Sub stream recording can be toggled on at runtime, so the cache can hold
a main-only sample while sub segments are already landing on disk.
Reporting 0 there would claim the sub stream costs nothing.
"""
config = FrigateConfig(**self.minimal_config)
storage = StorageMaintainer(config, MagicMock())
time_keep = datetime.datetime.now().timestamp()
for i in range(60):
_insert_mock_recording(
f"main_{i}.frontdoor",
os.path.join(self.test_dir, f"main_{i}.tmp"),
time_keep + i * 10,
time_keep + i * 10 + 10,
seg_size=20,
seg_dur=10,
)
# 50 or more segments flips needs_refresh off, freezing the cache
storage.calculate_camera_bandwidth()
assert storage.camera_storage_stats["front_door"]["needs_refresh"] is False
for i in range(60):
_insert_mock_recording(
f"sub_{i}.frontdoor",
os.path.join(self.test_dir, f"sub_{i}.tmp"),
time_keep + 5000 + i * 10,
time_keep + 5000 + i * 10 + 10,
seg_size=2,
seg_dur=10,
stream_type=STREAM_TYPE_SUB,
)
storage.calculate_camera_bandwidth()
streams = storage.calculate_camera_usages()["front_door"]["streams"]
assert streams[STREAM_TYPE_SUB]["usage"] == 120
assert streams[STREAM_TYPE_SUB]["bandwidth"] is None
assert streams[STREAM_TYPE_MAIN]["bandwidth"] == 7200
def test_camera_usages_with_no_recordings(self):
"""A camera with no segments reports zero usage and no streams."""
config = FrigateConfig(**self.minimal_config)
storage = StorageMaintainer(config, MagicMock())
storage.calculate_camera_bandwidth()
usages = storage.calculate_camera_usages()
assert usages["front_door"]["usage"] == 0
assert usages["front_door"]["streams"] == {}
def test_storage_cleanup(self):
"""Ensure that all recordings are cleaned up when necessary."""
config = FrigateConfig(**self.minimal_config)
@@ -332,6 +495,7 @@ def _insert_mock_recording(
camera="front_door",
seg_size=8,
seg_dur=10,
stream_type=STREAM_TYPE_MAIN,
) -> Event:
"""Inserts a basic recording model with a given id."""
# we must open the file so storage maintainer will delete it
@@ -348,4 +512,5 @@ def _insert_mock_recording(
motion=True,
objects=True,
segment_size=seg_size,
stream_type=stream_type,
).execute()