Compare commits

...
Author SHA1 Message Date
Josh Hawkins ef11f7bc50 delete timeline entries when expiring events without clips
Events without a clip were deleted once their snapshot expired, but their timeline rows were only removed when clip retention expired, so they were orphaned indefinitely. The timeline cache also held entries forever for events that ended without ever being saved.
2026-09-29 17:11:44 -05:00
3 changed files with 70 additions and 0 deletions
+1
View File
@@ -365,6 +365,7 @@ class EventCleanup(threading.Thread):
chunk = ids_to_delete[i : i + CHUNK_SIZE] chunk = ids_to_delete[i : i + CHUNK_SIZE]
logger.debug(f"Deleting {len(chunk)} events from the database") logger.debug(f"Deleting {len(chunk)} events from the database")
Event.delete().where(Event.id << chunk).execute() Event.delete().where(Event.id << chunk).execute()
Timeline.delete().where(Timeline.source_id << chunk).execute()
# embeddings are always cleaned up, even when semantic search # embeddings are always cleaned up, even when semantic search
# is disabled, so that they don't outlive their events # is disabled, so that they don't outlive their events
+66
View File
@@ -0,0 +1,66 @@
import unittest
from unittest.mock import MagicMock, patch
from frigate.events.types import EventStateEnum
from frigate.models import Timeline
from frigate.timeline import TimelineProcessor
def make_event(has_clip: bool, has_snapshot: bool) -> dict:
return {
"id": "event-1",
"frame_time": 1000.0,
"box": [0, 0, 10, 10],
"region": [0, 0, 100, 100],
"label": "car",
"sub_label": None,
"score": 0.8,
"has_clip": has_clip,
"has_snapshot": has_snapshot,
"current_zones": [],
"stationary": False,
"attributes": {},
"current_attributes": [],
}
class TestTimelineProcessor(unittest.TestCase):
def setUp(self):
camera_config = MagicMock()
camera_config.detect.width = 1280
camera_config.detect.height = 720
config = MagicMock()
config.cameras.get.return_value = camera_config
self.processor = TimelineProcessor(config, MagicMock(), MagicMock())
@patch.object(Timeline, "insert")
def test_unsaved_event_writes_no_timeline_rows(self, insert):
event = make_event(has_clip=False, has_snapshot=False)
self.processor.handle_object_detection(
"front", EventStateEnum.start, None, event
)
self.processor.handle_object_detection(
"front", EventStateEnum.end, event, event
)
insert.assert_not_called()
self.assertEqual(self.processor.pre_event_cache, {})
@patch.object(Timeline, "insert")
def test_cached_entries_flush_when_event_is_saved(self, insert):
start = make_event(has_clip=False, has_snapshot=False)
self.processor.handle_object_detection(
"front", EventStateEnum.start, None, start
)
insert.assert_not_called()
end = make_event(has_clip=True, has_snapshot=False)
self.processor.handle_object_detection("front", EventStateEnum.end, start, end)
class_types = [c.args[0][Timeline.class_type] for c in insert.call_args_list]
self.assertEqual(class_types, ["visible", "gone"])
self.assertEqual(self.processor.pre_event_cache, {})
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -191,6 +191,9 @@ class TimelineProcessor(threading.Thread):
timeline_entry[Timeline.class_type] = "gone" timeline_entry[Timeline.class_type] = "gone"
self.insert_or_save(timeline_entry, prev_event_data, event_data) self.insert_or_save(timeline_entry, prev_event_data, event_data)
# drop entries for events that ended without being saved
self.pre_event_cache.pop(event_id, None)
def handle_api_entry( def handle_api_entry(
self, self,
camera: str, camera: str,