Compare commits

...
Author SHA1 Message Date
gwmullinandGitHub f0009a5968 Merge b535fb0b27 into 5f6043aa92 2026-07-13 12:08:09 +02:00
Gdub b535fb0b27 Add a testcase for validating that on_conflict_ignore bypasses what was formerly an IntegrityError 2026-06-26 14:02:51 -07:00
Gdub 25cb4d9008 Add on_conflict_ignore, replacing the try/catch/pass on IntegrityError 2026-06-26 13:37:05 -07:00
Greg cb15ba5b60 Update peewee index migration description 2026-06-26 12:14:20 -07:00
Gdub ec1238e723 Remove the CTE and restore the generator with sub-queries, which is more performance (thanks Nick and Blake for testing against your larger DB!) 2026-06-26 11:08:53 -07:00
Greg c575fb223b Editor fail, re-ruff format. 2026-05-18 14:18:17 -07:00
Greg 9fa345f192 Remove 2x unnecessary index on reviewsegment, remove reference to prior code implementation in comment in event.py 2026-05-18 14:16:43 -07:00
Gdub 7b55c4b758 Rerun ruff formatting. 2026-05-18 13:39:12 -07:00
Gdub 570e2e3f76 Slightly simplify review logic and avoid duplicating the json response for empty review IDs. 2026-05-18 13:30:46 -07:00
Greg 39fba9b0a7 Use peewee instead of rw sql for the CTE query. 2026-05-11 16:46:43 -07:00
Greg 328a26b169 Collapse a few sequential queries into a single one. 2026-05-11 15:45:35 -07:00
Greg 311fb1bd19 Rewrite to use a CTE to leverage speedups by using sqllite internal optimization to do a single query instead of a starter query to get distinct labels and a subsequent loop of querys per distinct event labels.
Frigate is currently shipping sqlite 3.46.1, which is above the minimum version 3.25 needed for CTEs.
2026-05-08 16:18:37 -07:00
Greg 48b1426891 Add additional indicies on event and review tables. Every events or timeline endpoint filters on event start time and camera, this should speed things up by avoiding a range scan on the table. 2026-05-08 15:59:23 -07:00
4 changed files with 147 additions and 46 deletions
+28 -12
View File
@@ -386,7 +386,9 @@ def events_explore(
limit: int = 10, limit: int = 10,
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
): ):
# get distinct labels for all events if not allowed_cameras:
return JSONResponse(content=[])
distinct_labels = ( distinct_labels = (
Event.select(Event.label) Event.select(Event.label)
.where(Event.camera << allowed_cameras) .where(Event.camera << allowed_cameras)
@@ -396,13 +398,31 @@ def events_explore(
label_counts = {} label_counts = {}
explore_columns = (
Event.id,
Event.camera,
Event.label,
Event.sub_label,
Event.zones,
Event.start_time,
Event.end_time,
Event.has_clip,
Event.has_snapshot,
Event.plus_id,
Event.retain_indefinitely,
Event.top_score,
Event.false_positive,
Event.box,
Event.data,
)
def event_generator(): def event_generator():
for label_obj in distinct_labels.iterator(): for label_obj in distinct_labels.iterator():
label = label_obj.label label = label_obj.label
# get most recent events for this label # get most recent events for this label
label_events = ( label_events = (
Event.select() Event.select(*explore_columns)
.where((Event.label == label) & (Event.camera << allowed_cameras)) .where((Event.label == label) & (Event.camera << allowed_cameras))
.order_by(Event.start_time.desc()) .order_by(Event.start_time.desc())
.limit(limit) .limit(limit)
@@ -484,22 +504,18 @@ async def event_ids(ids: str, request: Request):
status_code=400, status_code=400,
) )
for event_id in ids:
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
# we should not fail the entire request if an event is not found
continue
try: try:
events = Event.select().where(Event.id << ids).dicts().iterator() events = list(Event.select().where(Event.id << ids).dicts().iterator())
return JSONResponse(list(events))
except Exception: except Exception:
return JSONResponse( return JSONResponse(
content=({"success": False, "message": "Events not found"}), status_code=400 content=({"success": False, "message": "Events not found"}), status_code=400
) )
for event in events:
await require_camera_access(event["camera"], request=request)
return JSONResponse(events)
@router.get( @router.get(
"/events/search", "/events/search",
+61 -34
View File
@@ -9,7 +9,7 @@ import pandas as pd
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from peewee import Case, DoesNotExist, IntegrityError, fn, operator from peewee import Case, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.auth import ( from frigate.api.auth import (
@@ -171,11 +171,19 @@ async def review_ids(request: Request, ids: str):
status_code=400, status_code=400,
) )
try:
reviews = list(
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
)
except Exception:
return JSONResponse(
content=({"success": False, "message": "Review segments not found"}),
status_code=400,
)
found_ids = {r["id"] for r in reviews}
for review_id in ids: for review_id in ids:
try: if review_id not in found_ids:
review = ReviewSegment.get(ReviewSegment.id == review_id)
await require_camera_access(review.camera, request=request)
except DoesNotExist:
return JSONResponse( return JSONResponse(
content=( content=(
{"success": False, "message": f"Review {review_id} not found"} {"success": False, "message": f"Review {review_id} not found"}
@@ -183,16 +191,10 @@ async def review_ids(request: Request, ids: str):
status_code=404, status_code=404,
) )
try: for review in reviews:
reviews = ( await require_camera_access(review["camera"], request=request)
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
) return JSONResponse(reviews)
return JSONResponse(list(reviews))
except Exception:
return JSONResponse(
content=({"success": False, "message": "Review segments not found"}),
status_code=400,
)
@router.get( @router.get(
@@ -489,27 +491,52 @@ async def set_multiple_reviewed(
user_id = current_user["username"] user_id = current_user["username"]
for review_id in body.ids: reviews = list(
try: ReviewSegment.select(ReviewSegment.id, ReviewSegment.camera).where(
review = ReviewSegment.get(ReviewSegment.id == review_id) ReviewSegment.id << body.ids
await require_camera_access(review.camera, request=request) )
review_status = UserReviewStatus.get( )
UserReviewStatus.user_id == user_id,
UserReviewStatus.review_segment == review_id, for review in reviews:
await require_camera_access(review.camera, request=request)
found_ids = [r.id for r in reviews]
if found_ids:
existing_statuses = list(
UserReviewStatus.select().where(
(UserReviewStatus.user_id == user_id)
& (UserReviewStatus.review_segment << found_ids)
) )
# Update based on the reviewed parameter )
if review_status.has_been_reviewed != body.reviewed:
review_status.has_been_reviewed = body.reviewed status_by_review = {s.review_segment_id: s for s in existing_statuses}
review_status.save()
except DoesNotExist: to_update = []
try: to_create = []
UserReviewStatus.create(
user_id=user_id, for review_id in found_ids:
review_segment=ReviewSegment.get(id=review_id), if review_id in status_by_review:
has_been_reviewed=body.reviewed, status = status_by_review[review_id]
if status.has_been_reviewed != body.reviewed:
status.has_been_reviewed = body.reviewed
to_update.append(status)
else:
to_create.append(
{
"user_id": user_id,
"review_segment_id": review_id,
"has_been_reviewed": body.reviewed,
}
) )
except (DoesNotExist, IntegrityError):
pass if to_update:
UserReviewStatus.bulk_update(
to_update, fields=[UserReviewStatus.has_been_reviewed], batch_size=100
)
if to_create:
UserReviewStatus.insert_many(to_create).on_conflict_ignore().execute()
return JSONResponse( return JSONResponse(
content=( content=(
+37
View File
@@ -497,6 +497,43 @@ class TestHttpReview(BaseTestHttp):
) )
assert user_review.has_been_reviewed == True assert user_review.has_been_reviewed == True
def test_post_reviews_viewed_concurrent_duplicate_does_not_raise(self):
"""Regression: concurrent requests marking the same review must not 500.
Two requests can both SELECT and find no existing status, then both try
to INSERT, hitting the unique (user_id, review_segment) constraint.
on_conflict_ignore() must silently skip the duplicate instead of raising
an IntegrityError (which was previously caught with try/except).
"""
id = "123456.random"
with AuthTestClient(self.app):
super().insert_mock_review_segment(id)
# Simulate the first request having already committed its insert.
self._insert_user_review_status(id, reviewed=True)
# Simulate the second concurrent request attempting the same insert.
UserReviewStatus.insert_many(
[
{
"user_id": self.user_id,
"review_segment_id": id,
"has_been_reviewed": True,
}
]
).on_conflict_ignore().execute()
# Exactly one row should exist; no exception should have been raised.
count = (
UserReviewStatus.select()
.where(
(UserReviewStatus.user_id == self.user_id)
& (UserReviewStatus.review_segment == id)
)
.count()
)
assert count == 1
#################################################################################################################### ####################################################################################################################
################################### POST reviews/delete Endpoint ################################################ ################################### POST reviews/delete Endpoint ################################################
#################################################################################################################### ####################################################################################################################
+21
View File
@@ -0,0 +1,21 @@
"""Peewee migrations -- 036_add_perf_indexes.py.
Adds composite/single-column indexes to speed up single-camera queries
issued by the web UI.
"""
import peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.sql(
'CREATE INDEX IF NOT EXISTS "event_camera_start_time" '
'ON "event" ("camera", "start_time" DESC)'
)
def rollback(migrator, database, fake=False, **kwargs):
migrator.sql('DROP INDEX IF EXISTS "event_camera_start_time"')