US07-06: Validate Performance and Resource Bounds (#93)
This commit was merged in pull request #93.
This commit is contained in:
@@ -15,10 +15,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy import String, and_, case, cast, func, or_, select, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from photo_pipeline.models import AnalysisResult, Asset
|
||||
@@ -71,38 +70,75 @@ class LibraryService:
|
||||
return {"rows": rows, "total": total, "offset": offset, "limit": limit}
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Library-wide totals, aggregated in SQL (US07-06).
|
||||
|
||||
This page used to load every analysis row — object, tags, and all — to count
|
||||
them in Python, which cost half a second at 100k assets and grew from there.
|
||||
Only the album breakdown still walks rows, and only their path and status:
|
||||
SQLite has no ``dirname``, and two short strings per asset is cheap.
|
||||
"""
|
||||
with self._session_factory() as session:
|
||||
status = dict(
|
||||
session.execute(
|
||||
select(AnalysisResult.status, func.count()).group_by(AnalysisResult.status)
|
||||
).all()
|
||||
)
|
||||
rows = list(
|
||||
session.execute(
|
||||
select(AnalysisResult, Asset.current_path).join(
|
||||
Asset, Asset.id == AnalysisResult.asset_id
|
||||
)
|
||||
)
|
||||
)
|
||||
albums: dict[str, dict] = {}
|
||||
tag_counts: Counter = Counter()
|
||||
year_counts: Counter = Counter()
|
||||
people: Counter = Counter()
|
||||
errors = []
|
||||
for result, path in rows:
|
||||
for path, row_status in session.execute(
|
||||
select(Asset.current_path, AnalysisResult.status).join(
|
||||
Asset, Asset.id == AnalysisResult.asset_id
|
||||
)
|
||||
):
|
||||
album = _album_of(path)
|
||||
bucket = albums.setdefault(album, {"album": album, "done": 0, "total": 0})
|
||||
bucket["total"] += 1
|
||||
if result.status in DONE:
|
||||
if row_status in DONE:
|
||||
bucket["done"] += 1
|
||||
for tag in _tags(result.tags):
|
||||
tag_counts[tag] += 1
|
||||
if result.approx_year is not None:
|
||||
year_counts[result.approx_year] += 1
|
||||
if result.people_count is not None:
|
||||
people["3+" if result.people_count >= 3 else str(result.people_count)] += 1
|
||||
if result.status == "error":
|
||||
errors.append({"path": path, "error": result.error_message})
|
||||
year_counts = dict(
|
||||
session.execute(
|
||||
select(AnalysisResult.approx_year, func.count())
|
||||
.where(AnalysisResult.approx_year.is_not(None))
|
||||
.group_by(AnalysisResult.approx_year)
|
||||
).all()
|
||||
)
|
||||
people = dict(
|
||||
session.execute(
|
||||
select(
|
||||
case(
|
||||
(AnalysisResult.people_count >= 3, "3+"),
|
||||
else_=cast(AnalysisResult.people_count, String),
|
||||
),
|
||||
func.count(),
|
||||
)
|
||||
.where(AnalysisResult.people_count.is_not(None))
|
||||
.group_by(
|
||||
case(
|
||||
(AnalysisResult.people_count >= 3, "3+"),
|
||||
else_=cast(AnalysisResult.people_count, String),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
# SQLite's JSON1 counts the tag arrays where they are: parsing 50k JSON
|
||||
# strings in Python to keep the top 40 is the definition of doing work
|
||||
# the database already does. Malformed tags are skipped, not fatal.
|
||||
tag_counts = session.execute(
|
||||
text(
|
||||
"SELECT tag.value AS value, count(*) AS total "
|
||||
"FROM analysis_results, json_each(analysis_results.tags) AS tag "
|
||||
"WHERE analysis_results.tags IS NOT NULL "
|
||||
"AND json_valid(analysis_results.tags) "
|
||||
"GROUP BY tag.value ORDER BY total DESC, value LIMIT 40"
|
||||
)
|
||||
).all()
|
||||
errors = [
|
||||
{"path": path, "error": message}
|
||||
for path, message in session.execute(
|
||||
select(Asset.current_path, AnalysisResult.error_message)
|
||||
.join(Asset, Asset.id == AnalysisResult.asset_id)
|
||||
.where(AnalysisResult.status == "error")
|
||||
)
|
||||
]
|
||||
return {
|
||||
"total": sum(status.values()),
|
||||
"status": status,
|
||||
@@ -111,7 +147,7 @@ class LibraryService:
|
||||
"season": self._facet("season"),
|
||||
"people": [{"value": v, "count": n} for v, n in sorted(people.items())],
|
||||
"years": [{"value": y, "count": year_counts[y]} for y in sorted(year_counts)],
|
||||
"top_tags": [{"value": t, "count": n} for t, n in tag_counts.most_common(40)],
|
||||
"top_tags": [{"value": t, "count": n} for t, n in tag_counts],
|
||||
"albums": sorted(albums.values(), key=lambda d: d["album"]),
|
||||
"errors": sorted(errors, key=lambda e: e["path"] or ""),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user