diff --git a/docker/main/build_sqlite_vec.sh b/docker/main/build_sqlite_vec.sh index b41f3383d9..8036c6f522 100755 --- a/docker/main/build_sqlite_vec.sh +++ b/docker/main/build_sqlite_vec.sh @@ -2,7 +2,7 @@ set -euxo pipefail -SQLITE_VEC_VERSION="0.1.3" +SQLITE_VEC_VERSION="0.1.9" source /etc/os-release diff --git a/frigate/api/event.py b/frigate/api/event.py index d37a547ba4..4e99d67035 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -1538,15 +1538,18 @@ async def set_description( event.data["description"] = new_description event.save() - # If semantic search is enabled, update the index - if request.app.frigate_config.semantic_search.enabled: - context: EmbeddingsContext = request.app.embeddings + context: EmbeddingsContext | None = request.app.embeddings + + if context is not None: if len(new_description) > 0: - context.update_description( - event_id, - new_description, - ) + # If semantic search is enabled, update the index + if request.app.frigate_config.semantic_search.enabled: + context.update_description( + event_id, + new_description, + ) else: + # embeddings are always cleaned up so they don't outlive their description context.db.delete_embeddings_description(event_ids=[event_id]) response_message = ( @@ -1675,9 +1678,11 @@ async def delete_single_event(event_id: str, request: Request) -> dict: event.delete_instance() Timeline.delete().where(Timeline.source_id == event_id).execute() - # If semantic search is enabled, update the index - if request.app.frigate_config.semantic_search.enabled: - context: EmbeddingsContext = request.app.embeddings + # embeddings are always cleaned up, even when semantic search is disabled, + # so that they don't outlive their events + context: EmbeddingsContext | None = request.app.embeddings + + if context is not None: context.db.delete_embeddings_thumbnail(event_ids=[event_id]) context.db.delete_embeddings_description(event_ids=[event_id]) diff --git a/frigate/app.py b/frigate/app.py index b0e29eab2a..5a39fc8a5b 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -270,7 +270,7 @@ class FrigateApp: 10 * len([c for c in self.config.cameras.values() if c.enabled_in_config]), ), - load_vec_extension=self.config.semantic_search.enabled, + load_vec_extension=True, ) models = [ Event, diff --git a/frigate/db/sqlitevecq.py b/frigate/db/sqlitevecq.py index 137fb51451..2d740f3736 100644 --- a/frigate/db/sqlitevecq.py +++ b/frigate/db/sqlitevecq.py @@ -1,9 +1,12 @@ +import logging import sqlite3 from typing import Any import regex from playhouse.sqliteq import SqliteQueueDatabase +logger = logging.getLogger(__name__) + REGEXP_TIMEOUT_SECONDS = 1.0 @@ -28,8 +31,14 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase): def _load_vec_extension(self, conn: sqlite3.Connection) -> None: conn.enable_load_extension(True) - conn.load_extension(self.sqlite_vec_path) - conn.enable_load_extension(False) + + try: + conn.load_extension(self.sqlite_vec_path) + except conn.OperationalError: + logger.error("Unable to load the sqlite-vec extension") + self.load_vec_extension = False + finally: + conn.enable_load_extension(False) def _register_regexp(self, conn: sqlite3.Connection) -> None: def regexp(expr: str, item: str | None) -> bool: @@ -44,13 +53,33 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase): conn.create_function("REGEXP", 2, regexp) - def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None: + def _delete_embeddings(self, table: str, event_ids: list[str]) -> None: + """Delete embeddings for the given events, if the table exists. + + Embeddings outlive the events they belong to when semantic search is + disabled, so deletes are attempted regardless of the current config. + """ + if not event_ids or not self.load_vec_extension: + return + + # the embeddings tables are only created once semantic search has run + cursor = self.execute_sql( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + (table,), + ) + + if cursor.fetchone() is None: + logger.debug("Skipping %s cleanup, table does not exist", table) + return + ids = ",".join(["?" for _ in event_ids]) - self.execute_sql(f"DELETE FROM vec_thumbnails WHERE id IN ({ids})", event_ids) + self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids) + + def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None: + self._delete_embeddings("vec_thumbnails", event_ids) def delete_embeddings_description(self, event_ids: list[str]) -> None: - ids = ",".join(["?" for _ in event_ids]) - self.execute_sql(f"DELETE FROM vec_descriptions WHERE id IN ({ids})", event_ids) + self._delete_embeddings("vec_descriptions", event_ids) def drop_embeddings_tables(self) -> None: self.execute_sql(""" diff --git a/frigate/events/cleanup.py b/frigate/events/cleanup.py index b867bf947b..88b6a9eda5 100644 --- a/frigate/events/cleanup.py +++ b/frigate/events/cleanup.py @@ -366,9 +366,10 @@ class EventCleanup(threading.Thread): logger.debug(f"Deleting {len(chunk)} events from the database") Event.delete().where(Event.id << chunk).execute() - if self.config.semantic_search.enabled: - self.db.delete_embeddings_description(event_ids=chunk) - self.db.delete_embeddings_thumbnail(event_ids=chunk) - logger.debug(f"Deleted {len(ids_to_delete)} embeddings") + # embeddings are always cleaned up, even when semantic search + # is disabled, so that they don't outlive their events + self.db.delete_embeddings_description(event_ids=chunk) + self.db.delete_embeddings_thumbnail(event_ids=chunk) + logger.debug(f"Deleted {len(chunk)} embeddings") logger.info("Exiting event cleanup...") diff --git a/frigate/test/test_sqlitevecq_embeddings.py b/frigate/test/test_sqlitevecq_embeddings.py new file mode 100644 index 0000000000..dc80cd96f8 --- /dev/null +++ b/frigate/test/test_sqlitevecq_embeddings.py @@ -0,0 +1,63 @@ +"""Tests for embedding cleanup on the main Frigate database. + +Embeddings are deleted whether or not semantic search is currently enabled, so +the delete path has to tolerate databases where the vec0 tables were never +created and installs where the sqlite-vec extension is unavailable. +""" + +import os +import tempfile +import unittest + +from frigate.db.sqlitevecq import SqliteVecQueueDatabase + + +class TestDeleteEmbeddings(unittest.TestCase): + def setUp(self) -> None: + self.tmp_dir = tempfile.TemporaryDirectory() + self.db = SqliteVecQueueDatabase(os.path.join(self.tmp_dir.name, "test.db")) + self.db.start() + # the extension is not available to tests, so stand in for a database + # that has it loaded and use a plain table for the deletes + self.db.load_vec_extension = True + + def tearDown(self) -> None: + self.db.stop() + self.db.close() + self.tmp_dir.cleanup() + + def _flush_writes(self) -> None: + # writes are queued and applied by a worker thread, and the queue is + # FIFO, so awaiting a later write means the earlier ones are done + self.db.execute_sql("PRAGMA user_version = 0").fetchall() + + def _create_thumbnails_table(self) -> None: + self.db.execute_sql("CREATE TABLE vec_thumbnails (id TEXT PRIMARY KEY)") + self.db.execute_sql("INSERT INTO vec_thumbnails (id) VALUES ('a'), ('b')") + self._flush_writes() + + def _thumbnail_ids(self) -> list[str]: + return [row[0] for row in self.db.execute_sql("SELECT id FROM vec_thumbnails")] + + def test_delete_without_tables_does_not_raise(self) -> None: + # semantic search was never enabled, so event cleanup has nothing to do + self.db.delete_embeddings_thumbnail(event_ids=["1700000000.0-abc"]) + self.db.delete_embeddings_description(event_ids=["1700000000.0-abc"]) + + def test_delete_removes_embeddings(self) -> None: + self._create_thumbnails_table() + + self.db.delete_embeddings_thumbnail(event_ids=["a"]) + self._flush_writes() + + self.assertEqual(self._thumbnail_ids(), ["b"]) + + def test_delete_skipped_without_extension(self) -> None: + self._create_thumbnails_table() + self.db.load_vec_extension = False + + self.db.delete_embeddings_thumbnail(event_ids=["a"]) + self._flush_writes() + + # the vec0 tables cannot be written without the extension + self.assertEqual(self._thumbnail_ids(), ["a", "b"])