From e37041c879e150012cf8892ec0ee2f137998a6b1 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:02:45 -0500 Subject: [PATCH] delete timeline entries when expiring events without clips (#24510) 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. --- frigate/events/cleanup.py | 1 + frigate/test/test_timeline.py | 66 +++++++++++++++++++++++++++++++++++ frigate/timeline.py | 3 ++ 3 files changed, 70 insertions(+) create mode 100644 frigate/test/test_timeline.py diff --git a/frigate/events/cleanup.py b/frigate/events/cleanup.py index 88b6a9eda5..1369ab463d 100644 --- a/frigate/events/cleanup.py +++ b/frigate/events/cleanup.py @@ -365,6 +365,7 @@ class EventCleanup(threading.Thread): chunk = ids_to_delete[i : i + CHUNK_SIZE] logger.debug(f"Deleting {len(chunk)} events from the database") Event.delete().where(Event.id << chunk).execute() + Timeline.delete().where(Timeline.source_id << chunk).execute() # embeddings are always cleaned up, even when semantic search # is disabled, so that they don't outlive their events diff --git a/frigate/test/test_timeline.py b/frigate/test/test_timeline.py new file mode 100644 index 0000000000..a438528ab1 --- /dev/null +++ b/frigate/test/test_timeline.py @@ -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() diff --git a/frigate/timeline.py b/frigate/timeline.py index d82f17cb7d..cc864c7bc3 100644 --- a/frigate/timeline.py +++ b/frigate/timeline.py @@ -191,6 +191,9 @@ class TimelineProcessor(threading.Thread): timeline_entry[Timeline.class_type] = "gone" 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( self, camera: str,