mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 18:58:56 +03:00
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:
committed by
Nicolas Mowen
parent
ad04101c76
commit
6ba9dd92e4
+40
-8
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user