Files
frigate/frigate/db/sqlitevecq.py
T
Josh HawkinsandGitHub 26e6adee88
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
Fix semantic search reindex (#24407)
* fix semantic search reindex

sqlite-vec added the `_info` shadow table in 0.1.6 and drops it unconditionally when a vec0 table is destroyed, so `DROP TABLE` on a table written by 0.17 failed with "SQL logic error" once 0.18 moved to 0.1.9. `SqliteQueueDatabase` queues non-SELECT statements and stores the exception on the cursor it returns, and nothing read those cursors, so the failed drop and every write after it went unreported while reindex still logged "Embedded N thumbnails". `drop_embeddings_tables()` now recreates the missing `_info` stub before dropping, and writes go through `execute_write()`, which waits on the cursor so failures raise. `INSERT OR REPLACE` is gone too, since vec0 implements neither REPLACE nor UPSERT and it always failed on an id already in the table, including under the 0.1.3 build 0.17 shipped.

* use lock

* show reindex failure in status bar
2026-09-19 08:10:36 -06:00

167 lines
6.2 KiB
Python

import logging
import sqlite3
import threading
from typing import Any
import regex
from peewee import DatabaseError
from playhouse.sqliteq import SqliteQueueDatabase
logger = logging.getLogger(__name__)
REGEXP_TIMEOUT_SECONDS = 1.0
class SqliteVecQueueDatabase(SqliteQueueDatabase):
def __init__(
self, *args: Any, load_vec_extension: bool = False, **kwargs: Any
) -> None:
self.load_vec_extension: bool = load_vec_extension
# no extension necessary, sqlite will load correctly for each platform
self.sqlite_vec_path = "/usr/local/lib/vec0"
self.upsert_lock = threading.Lock()
super().__init__(*args, **kwargs)
def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection:
conn: sqlite3.Connection = super()._connect(*args, **kwargs) # type: ignore[misc]
if self.load_vec_extension:
self._load_vec_extension(conn)
# register REGEXP support
self._register_regexp(conn)
return conn
def _load_vec_extension(self, conn: sqlite3.Connection) -> None:
conn.enable_load_extension(True)
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:
if item is None:
return False
try:
return (
regex.search(expr, item, timeout=REGEXP_TIMEOUT_SECONDS) is not None
)
except (regex.error, TimeoutError):
return False
conn.create_function("REGEXP", 2, regexp)
def execute_write(self, sql: str, params: Any = None) -> None:
"""Run a write and wait for it, so that failures are raised here.
SqliteQueueDatabase hands non-SELECT statements to a writer thread and
stores any exception on the cursor it returns, so callers that ignore
that cursor never learn the write failed.
"""
self.execute_sql(sql, params).fetchall()
def _table_exists(self, table: str) -> bool:
cursor = self.execute_sql(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
)
return cursor.fetchone() is not 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
if not self._table_exists(table):
logger.debug("Skipping %s cleanup, table does not exist", table)
return
ids = ",".join(["?" for _ in event_ids])
# callers treat cleanup as best effort, so log rather than propagate
try:
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
except DatabaseError:
logger.exception("Failed to delete embeddings from %s", table)
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:
self._delete_embeddings("vec_descriptions", event_ids)
def _restore_vec_info_table(self, table: str) -> None:
"""Recreate the _info shadow table a legacy vec0 table is missing.
sqlite-vec added _info in 0.1.6 and drops it unconditionally when a
table is destroyed, so tables written by Frigate 0.17 and earlier fail
to drop. An empty stub is enough, and leaving it unseeded keeps the
table reading as pre-0.1.10 if the drop does not follow.
"""
if not self._table_exists(table) or self._table_exists(f"{table}_info"):
return
logger.debug("Restoring the %s_info shadow table before dropping", table)
self.execute_write(
f'CREATE TABLE "{table}_info" (key TEXT PRIMARY KEY, value ANY)'
)
def drop_embeddings_tables(self) -> None:
for table in ("vec_descriptions", "vec_thumbnails"):
self._restore_vec_info_table(table)
self.execute_write(f"DROP TABLE IF EXISTS {table}")
def create_embeddings_tables(self) -> None:
"""Create vec0 virtual table for embeddings"""
self.execute_write("""
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0(
id TEXT PRIMARY KEY,
thumbnail_embedding FLOAT[768] distance_metric=cosine
);
""")
self.execute_write("""
CREATE VIRTUAL TABLE IF NOT EXISTS vec_descriptions USING vec0(
id TEXT PRIMARY KEY,
description_embedding FLOAT[768] distance_metric=cosine
);
""")
def upsert_embeddings(
self, table: str, column: str, embeddings: dict[str, bytes]
) -> None:
"""Write embeddings for the given event ids, replacing any that exist.
vec0 implements neither REPLACE nor UPSERT, so rows that are already
there have to be deleted first.
"""
if not embeddings:
return
event_ids = list(embeddings.keys())
ids = ",".join(["?" for _ in event_ids])
params: list[Any] = []
for event_id in event_ids:
params.extend((event_id, embeddings[event_id]))
values = ", ".join(["(?, ?)"] * len(event_ids))
# reindexing and live embedding run on separate threads, and each write
# is queued separately, so the delete and the insert have to be held
# together or an interleaved pair fails on the vec0 primary key
with self.upsert_lock:
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
self.execute_write(
f"INSERT INTO {table}(id, {column}) VALUES {values}", params
)