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.
This commit is contained in:
Josh Hawkins
2026-09-29 17:11:44 -05:00
parent 61e50a366f
commit ef11f7bc50
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]
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
+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"
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,