From ef11f7bc5074953ba79a87e9be2ab8d3f128dc9a Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:11:44 -0500 Subject: [PATCH] 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. --- 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,