db: serializer loads per query; the guard ignores transaction control

The query guard reported the engine's own BEGIN (emitted through the same event as the first statement on a
Session with no open transaction) instead of the lazy load after it, and in production that BEGIN used up the
one warning per serializer.  Transaction-control statements are now ignored.

Mapper-level eager loads reached background code: every Video/Archive load in the modelers (one per file,
inside the write transaction) joined file_group/channel/collection/tag and selectin-loaded the Collection's
Download rows with their yt-dlp info_json.  A second refresh of 100 videos took 0.40 s instead of 0.16 s.

Serializer needs are now declared per model (`json_options()`) and added only by the queries whose results
are serialized; Download.info_json is deferred there (no serializer reads it).  Mapper defaults keep only
narrow joins nearly every use needs: TagFile.tag, Collection.tag and Channel.collection.  A second refresh is
back to master's statement count and time; search still drops from 55 to 3 statements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-10-04 18:00:30 -06:00
parent 72a0379a7f
commit 021bdad947
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
18 changed files with 153 additions and 58 deletions

View file

@ -5,7 +5,7 @@ from typing import Iterable, List, Optional
import pytz
from sqlalchemy import Column, Integer, ForeignKey, BigInteger, Index
from sqlalchemy.orm import relationship, Session
from sqlalchemy.orm import relationship, Session, joinedload
from wrolpi import dates
from wrolpi.collections import Collection
@ -36,10 +36,15 @@ class Archive(Base, ModelHelper):
id = Column(Integer, primary_key=True)
collection_id = Column(Integer, ForeignKey('collection.id', ondelete='CASCADE'))
collection: Collection = relationship('Collection', primaryjoin='Archive.collection_id==Collection.id',
lazy='joined')
collection: Collection = relationship('Collection', primaryjoin='Archive.collection_id==Collection.id')
file_group_id = Column(BigInteger, ForeignKey('file_group.id', ondelete='CASCADE'), unique=True, nullable=False)
file_group: FileGroup = relationship('FileGroup', lazy='joined')
file_group: FileGroup = relationship('FileGroup')
@staticmethod
def json_options() -> tuple:
"""Loader options for a query whose Archives will be serialized: `__json__` reads the FileGroup and its Tags,
and the domain (the Collection's name)."""
return joinedload(Archive.file_group).selectinload(FileGroup.tag_files), joinedload(Archive.collection)
@serializer
def __json__(self) -> dict:

View file

@ -340,7 +340,7 @@ def _doc_response(doc) -> dict:
def _get_doc(session, file_group_id: int):
from .models import Doc
doc = session.query(Doc).filter_by(file_group_id=file_group_id).one_or_none()
doc = session.query(Doc).options(*Doc.json_options()).filter_by(file_group_id=file_group_id).one_or_none()
if not doc:
raise ValidationError(f'Doc with file_group_id {file_group_id} not found')
return doc
@ -375,7 +375,7 @@ def _search_docs(session: Session, search_str=None, author=None, subject=None, l
# the much smaller a/b/c columns.
match = fts.translate_websearch(search_str, None if deep else fts.ABC_COLUMNS) if search_str else None
query = session.query(FileGroup).join(Doc, Doc.file_group_id == FileGroup.id)
query = session.query(FileGroup).options(*FileGroup.json_options()).join(Doc, Doc.file_group_id == FileGroup.id)
fts_sq = None
if match is not None:
@ -441,7 +441,8 @@ def _search_docs(session: Session, search_str=None, author=None, subject=None, l
browse_sql = _doc_browse_sql(order_by if order_by in DOC_BROWSE_ORDERS else 'id')
if browse_sql:
fg_ids = [i for i, in session.execute(text(browse_sql), dict(limit=limit, offset=offset)).fetchall()]
by_id = {fg.id: fg for fg in session.query(FileGroup).filter(FileGroup.id.in_(fg_ids))} if fg_ids else {}
by_id = {fg.id: fg for fg in session.query(FileGroup).options(*FileGroup.json_options())
.filter(FileGroup.id.in_(fg_ids))} if fg_ids else {}
file_groups = [by_id[i] for i in fg_ids]
else:
file_groups = query.offset(offset).limit(limit).all()

View file

@ -2,7 +2,7 @@ import pathlib
from typing import Optional
from sqlalchemy import Column, Integer, BigInteger, ForeignKey, String, Text, Index, or_
from sqlalchemy.orm import relationship, Session
from sqlalchemy.orm import relationship, Session, joinedload
from wrolpi.common import ModelHelper, Base
from wrolpi.db import get_db_session, serializer
@ -56,7 +56,7 @@ class Doc(ModelHelper, Base):
description = Column(Text)
file_group_id = Column(BigInteger, ForeignKey('file_group.id', ondelete='CASCADE'), nullable=False, unique=True)
file_group: FileGroup = relationship('FileGroup', lazy='joined')
file_group: FileGroup = relationship('FileGroup')
sections = relationship('DocSection', back_populates='doc',
cascade='all, delete-orphan',
passive_deletes=True)
@ -65,6 +65,11 @@ class Doc(ModelHelper, Base):
path = str(self.file_group.primary_path) if self.file_group else 'None'
return f'<Doc id={self.id} path={repr(path)} file_group_id={self.file_group_id}>'
@staticmethod
def json_options() -> tuple:
"""Loader options for a query whose Docs will be serialized: `__json__` reads the FileGroup and its Tags."""
return joinedload(Doc.file_group).selectinload(FileGroup.tag_files),
@serializer
def __json__(self) -> dict:
d = self.file_group.__json__()

View file

@ -208,6 +208,7 @@ async def search_channels_by_name(session: Session, name: str, limit: int = 5,
name_order = (func.lower(func.replace(Collection.name, ' ', '')).asc(), Collection.name.desc())
if order_by_video_count:
stmt = session.query(Channel, func.count(Video.id).label('video_count')) \
.options(*Channel.json_options()) \
.join(Collection) \
.filter(or_(
Collection.name.ilike(f'%{name}%'),
@ -220,6 +221,7 @@ async def search_channels_by_name(session: Session, name: str, limit: int = 5,
channels = [i[0] for i in stmt]
else:
stmt = session.query(Channel) \
.options(*Channel.json_options()) \
.join(Collection) \
.filter(or_(
Collection.name.ilike(f'%{name}%'),
@ -319,5 +321,6 @@ async def tag_channel(session: Session, tag_name: str | None, directory: pathlib
async def search_channels(session: Session, tag_names: List[str]) -> List[Channel]:
"""Search Tagged Channels."""
channels = session.query(Channel).join(Collection).join(Tag).filter(Tag.name.in_(tag_names)).all()
channels = session.query(Channel).options(*Channel.json_options()) \
.join(Collection).join(Tag).filter(Tag.name.in_(tag_names)).all()
return channels

View file

@ -3,7 +3,7 @@ import pathlib
from typing import Optional, Dict, List
from sqlalchemy import Column, Integer, String, Boolean, Date, ForeignKey, BigInteger, Index, text, JSON
from sqlalchemy.orm import relationship, Session, deferred, undefer
from sqlalchemy.orm import relationship, Session, deferred, undefer, joinedload, selectinload
from sqlalchemy.orm.collections import InstrumentedList
from modules.videos.errors import UnknownVideo, UnknownChannel
@ -11,6 +11,7 @@ from wrolpi.captions import read_captions, read_captions_with_timestamps
from wrolpi.common import Base, ModelHelper, logger, get_media_directory, get_relative_to_media_directory, \
background_task
from wrolpi.db import get_db_curs, get_db_session, session_curs, serializer
from wrolpi.collections.models import Collection
from wrolpi.downloader import Download
from wrolpi.files.lib import split_path_stem_and_suffix
from wrolpi.files.worker import file_worker
@ -46,10 +47,9 @@ class Video(ModelHelper, Base):
comments_failed = Column(Boolean, default=False) # see `get_missing_videos_comments`
channel_id = Column(Integer, ForeignKey('channel.id'))
channel = relationship('Channel', primaryjoin='Video.channel_id==Channel.id', back_populates='videos',
lazy='joined')
channel = relationship('Channel', primaryjoin='Video.channel_id==Channel.id', back_populates='videos')
file_group_id = Column(BigInteger, ForeignKey('file_group.id', ondelete='CASCADE'), unique=True, nullable=False)
file_group: FileGroup = relationship('FileGroup', lazy='joined')
file_group: FileGroup = relationship('FileGroup')
def __repr__(self):
v = None
@ -58,6 +58,13 @@ class Video(ModelHelper, Base):
return f'<Video id={self.id} title={repr(self.file_group.title)} path={v} channel={self.channel_id} ' \
f'source_id={repr(self.source_id)}>'
@staticmethod
def json_options() -> tuple:
"""Loader options for a query whose Videos will be serialized: `__json__` reads the codecs in the deferred
ffprobe_json, the FileGroup and its Tags, and the Channel (its Collection loads with it)."""
return undefer(Video.ffprobe_json), joinedload(Video.file_group).selectinload(FileGroup.tag_files), \
joinedload(Video.channel)
@serializer
def __json__(self) -> dict:
d = self.file_group.__json__()
@ -235,8 +242,9 @@ class Video(ModelHelper, Base):
break
# Fetch the videos by id, if they exist.
previous_video = Video.find_by_id(session, previous_id) if previous_id else None
next_video = Video.find_by_id(session, next_id) if next_id else None
# The neighbors are serialized next to this Video.
previous_video = Video.find_by_id(session, previous_id, Video.json_options()) if previous_id else None
next_video = Video.find_by_id(session, next_id, Video.json_options()) if next_id else None
return previous_video, next_video
@ -393,30 +401,27 @@ class Video(ModelHelper, Base):
return video
@staticmethod
def get_by_id(session: Session, id_: int) -> Optional['Video']:
def get_by_id(session: Session, id_: int, options: tuple = ()) -> Optional['Video']:
"""Attempt to find a Video with the provided id. Returns None if it cannot be found."""
# `__json__` reads the (deferred) ffprobe_json for the codecs.
video = session.query(Video).options(undefer(Video.ffprobe_json)).filter(Video.id == id_).one_or_none()
video = session.query(Video).options(*options).filter(Video.id == id_).one_or_none()
return video
@staticmethod
def find_by_id(session: Session, id_: int) -> 'Video':
def find_by_id(session: Session, id_: int, options: tuple = ()) -> 'Video':
"""Find a Video with the provided id, raises an exception if it cannot be found.
@raise UnknownVideo: if the Video can not be found"""
video = Video.get_by_id(session, id_)
video = Video.get_by_id(session, id_, options)
if not video:
raise UnknownVideo(f'Cannot find Video with id {id_}')
return video
@staticmethod
def find_by_file_group_id(session: Session, file_group_id: int) -> 'Video':
def find_by_file_group_id(session: Session, file_group_id: int, options: tuple = ()) -> 'Video':
"""Find a Video by its FileGroup ID, raises an exception if it cannot be found.
@raise UnknownVideo: if the Video can not be found"""
# `__json__` reads the (deferred) ffprobe_json for the codecs.
video = session.query(Video).options(undefer(Video.ffprobe_json)) \
.filter(Video.file_group_id == file_group_id).one_or_none()
video = session.query(Video).options(*options).filter(Video.file_group_id == file_group_id).one_or_none()
if not video:
raise UnknownVideo(f'Cannot find Video with file_group_id {file_group_id}')
return video
@ -625,6 +630,7 @@ class Channel(ModelHelper, Base):
videos: InstrumentedList = relationship('Video', primaryjoin='Channel.id==Video.channel_id')
collection_id = Column(Integer, ForeignKey('collection.id', ondelete='CASCADE'))
# Joined: a Channel's name, directory and Tag live on its Collection.
collection = relationship('Collection', foreign_keys=[collection_id], lazy='joined')
@property
@ -943,6 +949,12 @@ class Channel(ModelHelper, Base):
return None
@staticmethod
def json_options() -> tuple:
"""Loader options for a query whose Channels will be serialized: `__json__` lists the Collection's
Downloads (without their yt-dlp info_json, which is not serialized)."""
return joinedload(Channel.collection).selectinload(Collection.downloads).defer(Download.info_json),
@serializer
def __json__(self) -> dict:
d = dict(

View file

@ -127,8 +127,9 @@ async def test_video_channel_refresh(async_client, test_session, test_directory,
video2: Video = Video.get_by_path(test_session, video2_path)
assert video1.channel == channel
assert not video2.channel
# Serializers do not query: load the deferred ffprobe_json first, as the queries that serialize Videos do.
test_session.refresh(video1, ['ffprobe_json'])
# Serializers do not query: load what Video's `__json__` reads, as the queries that serialize Videos do.
video1 = test_session.query(Video).options(*Video.json_options()).populate_existing() \
.filter(Video.id == video1.id).one()
assert video1.__json__()['video']['channel']
video_channel = video1.__json__()['video']['channel']
assert video_channel['id'] == channel.id

View file

@ -30,7 +30,7 @@ def get_video_for_app(session: Session, file_group_id: int, skip_viewed: bool =
Get a Video by its FileGroup ID, with its prev/next videos. Mark the Video as viewed (a write; the
caller commits).
"""
video = Video.find_by_file_group_id(session, file_group_id)
video = Video.find_by_file_group_id(session, file_group_id, Video.json_options())
if not skip_viewed:
video.file_group.set_viewed()
previous_video, next_video = video.get_surrounding_videos()

View file

@ -323,7 +323,7 @@ def get_kiwix_catalog():
def get_kiwix_subscriptions(session: Session) -> Dict[str, ZimSubscription]:
subscriptions = session.query(ZimSubscription)
subscriptions = session.query(ZimSubscription).options(*ZimSubscription.json_options())
subscriptions_by_name = {i.name: i for i in subscriptions}
return subscriptions_by_name

View file

@ -7,7 +7,7 @@ from typing import List, Tuple, OrderedDict as OrderedDictType, Dict, Optional,
from libzim import Archive, Searcher, Query, Entry, SuggestionSearcher
from sqlalchemy import Column, Integer, BigInteger, ForeignKey, Text, tuple_, Boolean, UniqueConstraint
from sqlalchemy.orm import relationship, Session
from sqlalchemy.orm import relationship, Session, joinedload
from sqlalchemy.orm.exc import NoResultFound # noqa
from modules.zim.errors import UnknownZimEntry, UnknownZimTagEntry, UnknownZim
@ -74,12 +74,17 @@ class Zim(Base, ModelHelper):
path: pathlib.Path = Column(MediaPathType, nullable=False)
file_group_id = Column(BigInteger, ForeignKey('file_group.id', ondelete='CASCADE'), nullable=False)
file_group: FileGroup = relationship('FileGroup', lazy='joined')
file_group: FileGroup = relationship('FileGroup')
auto_search = Column(Boolean, default=True)
def __repr__(self):
return f'<Zim id={self.id} file_group_id={self.file_group_id} path={self.path}>'
@staticmethod
def json_options() -> tuple:
"""Loader options for a query whose Zims will be serialized: `__json__` reads the FileGroup's size."""
return joinedload(Zim.file_group),
@serializer
def __json__(self) -> dict:
d = dict(
@ -320,7 +325,7 @@ class Zims:
def get_all(session: Session) -> List[Zim]:
"""Returns all Zim records."""
zims = list()
for zim in session.query(Zim).order_by(Zim.path):
for zim in session.query(Zim).options(*Zim.json_options()).order_by(Zim.path):
try:
# It may not be possible to read the Zim file (drive not mounted), report and ignore.
zim.get_zim()
@ -383,8 +388,7 @@ class ZimSubscription(Base):
name: str = Column(Text, unique=True, nullable=False)
language: str = Column(Text, nullable=False)
download_id: int = Column(Integer, ForeignKey('download.id', ondelete='CASCADE'), nullable=False)
download: Download = relationship('Download', primaryjoin='ZimSubscription.download_id==Download.id',
lazy='joined')
download: Download = relationship('Download', primaryjoin='ZimSubscription.download_id==Download.id')
def __repr__(self):
name = self.name
@ -392,6 +396,11 @@ class ZimSubscription(Base):
download_id = self.download_id
return f'<ZimSubscription id={self.id} {name=} {language=} {download_id=}>'
@staticmethod
def json_options() -> tuple:
"""Loader options for a query whose subscriptions will be serialized: `__json__` reads the Download's URL."""
return joinedload(ZimSubscription.download).defer(Download.info_json),
@serializer
def __json__(self) -> dict:
d = dict(

View file

@ -14,6 +14,7 @@ from wrolpi.errors import ValidationError
from wrolpi.schema import JSONErrorResponse
from . import lib, schema
from .errors import UnknownCollection
from .models import CollectionItem
# Create blueprint
collection_bp = Blueprint('Collection', url_prefix='/api/collections')
@ -98,6 +99,9 @@ async def add_collection_item_endpoint(request: Request, collection_id: int, bod
url=body.url, title=body.title, position=body.position)
# 201 when a new item was created; 200 when the item already existed (idempotent add).
status = HTTPStatus.CREATED if created else HTTPStatus.OK
# Load what `dict()` serializes (the FileGroup's Tags) before serializing.
item = session.query(CollectionItem).options(*CollectionItem.json_options()).populate_existing() \
.filter(CollectionItem.id == item.id).one()
return json_response({'item': item.dict()}, status=status)
except UnknownCollection as e:
return json_response({'error': str(e)}, status=HTTPStatus.NOT_FOUND)

View file

@ -14,7 +14,7 @@ from wrolpi import flags
from wrolpi.common import logger, get_relative_to_media_directory, TRACE_LEVEL, background_task
from wrolpi.db import get_db_session
from wrolpi.errors import FileWorkerConflict
from wrolpi.errors import ValidationError
from wrolpi.errors import UnknownCollection, ValidationError
from wrolpi.events import Events
from wrolpi.tags import Tag
from .models import Collection, validate_collection_directory
@ -183,7 +183,9 @@ def get_collection_with_stats(session: Session, collection_id: int) -> dict:
Raises:
UnknownCollection: If collection not found
"""
collection = Collection.find_by_id(session, collection_id)
collection = session.query(Collection).options(*Collection.json_options()).filter_by(id=collection_id).one_or_none()
if not collection:
raise UnknownCollection(f'Cannot find Collection with id {collection_id}')
# Get base collection data
data = collection.__json__()
@ -236,7 +238,9 @@ def get_collection_with_stats(session: Session, collection_id: int) -> dict:
# Playlists are manually curated and ordered, so return the full ordered item list.
if collection.kind == 'playlist':
data['items'] = [item.dict() for item in collection.items]
items = session.query(CollectionItem).options(*CollectionItem.json_options()) \
.filter(CollectionItem.collection_id == collection_id).order_by(CollectionItem.position)
data['items'] = [item.dict() for item in items]
return data

View file

@ -3,7 +3,7 @@ from typing import Optional, List
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Index, UniqueConstraint, func, BigInteger, \
or_, CheckConstraint
from sqlalchemy.orm import relationship, Session
from sqlalchemy.orm import relationship, Session, joinedload, selectinload
from sqlalchemy.orm.collections import InstrumentedList
from wrolpi import flags
@ -90,6 +90,7 @@ class Collection(ModelHelper, Base):
# Optional tag relationship (similar to Channel)
tag_id = Column(Integer, ForeignKey('tag.id'))
# Joined: one narrow row, read by the Collection's (and its Channel's) `tag_name`.
tag = relationship('Tag', primaryjoin='Collection.tag_id==Tag.id', lazy='joined')
# Stores the file_name_format used when files were last organized
@ -116,7 +117,6 @@ class Collection(ModelHelper, Base):
'Download',
primaryjoin='Download.collection_id==Collection.id',
back_populates='collection',
lazy='selectin',
)
def __repr__(self):
@ -945,6 +945,12 @@ class Collection(ModelHelper, Base):
self.tag_id = self.tag.id if self.tag else None
return self.tag
@staticmethod
def json_options() -> tuple:
"""Loader options for a query whose Collections will be serialized: `__json__` lists the Downloads
(without their yt-dlp info_json, which is not serialized)."""
return selectinload(Collection.downloads).defer(Download.info_json),
@serializer
def __json__(self) -> dict:
"""Return JSON-serializable dict for API responses.
@ -1188,9 +1194,8 @@ class CollectionItem(ModelHelper, Base):
# Relationships
collection = relationship('Collection', back_populates='items')
# Eager: `dict()` serializes the item's FileGroup or Zim for every item of a listing.
file_group: FileGroup = relationship('FileGroup', lazy='joined')
zim = relationship('Zim', lazy='joined')
file_group: FileGroup = relationship('FileGroup')
zim = relationship('Zim')
# Indexes for performance
__table_args__ = (
@ -1217,6 +1222,12 @@ class CollectionItem(ModelHelper, Base):
return (f'<CollectionItem collection={self.collection_id} kind={self.item_kind} '
f'position={self.position}>')
@staticmethod
def json_options() -> tuple:
"""Loader options for a query whose items will be serialized (`dict()`): each reads its FileGroup and the
FileGroup's Tags, or its Zim."""
return joinedload(CollectionItem.file_group).selectinload(FileGroup.tag_files), joinedload(CollectionItem.zim)
def dict(self) -> dict:
"""Return dictionary representation."""
d = super(CollectionItem, self).dict()

View file

@ -36,8 +36,7 @@ logger = logger.getChild(__name__)
_immediate_txn = contextvars.ContextVar('wrolpi_immediate_txn', default=False)
# Set while serializing (`no_db_access`). A query then is a lazy load or an expired-attribute refresh: hidden
# per-row SQL today, and a `MissingGreenlet` error under an async Session. Holds the name of what is serializing.
# The name of the serializer that is running (see `no_db_access`), or None.
_no_db_access = contextvars.ContextVar('wrolpi_no_db_access', default=None)
# Serializers that have already logged an unexpected query in this process (see `_refuse_query_while_serializing`).
_warned_serializers = set()
@ -74,9 +73,14 @@ def serializer(method):
return wrapper
# Transaction control is not a query. The engine's `begin` listener emits BEGIN through this same event, as the
# first statement on a Session with no open transaction; the query that follows is the one to report.
_TRANSACTION_CONTROL = ('BEGIN', 'COMMIT', 'ROLLBACK', 'SAVEPOINT', 'RELEASE')
def _refuse_query_while_serializing(conn, cursor, statement, parameters, context, executemany):
reason = _no_db_access.get()
if reason is None:
if reason is None or statement.lstrip().upper().startswith(_TRANSACTION_CONTROL):
return
if PYTEST:
UNEXPECTED_QUERIES.append((reason, statement))

View file

@ -22,7 +22,7 @@ from typing import Callable, List, Tuple, Union, Dict, Generator, Iterable, Set
import cachetools.func
from sqlalchemy import asc, or_, text as sa_text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, undefer
from sqlalchemy.orm import Session, undefer, joinedload
from wrolpi.cmd import which
from wrolpi.common import get_media_directory, wrol_mode_check, logger, \
@ -234,6 +234,7 @@ def get_tagged_file_groups_by_ids(session: Session, file_group_ids: List[int]) -
if not file_group_ids:
return []
rows = session.query(FileGroup) \
.options(*FileGroup.json_options()) \
.join(TagFile, TagFile.file_group_id == FileGroup.id) \
.filter(FileGroup.id.in_(file_group_ids)) \
.distinct() \
@ -255,6 +256,7 @@ def get_tagged_file_groups_by_paths(session: Session, paths: Iterable[pathlib.Pa
seen_ids = set()
for path in paths:
query = session.query(FileGroup) \
.options(*FileGroup.json_options()) \
.join(TagFile, TagFile.file_group_id == FileGroup.id)
if path.is_file():
# Could be deleting a file in a FileGroup that has been tagged.
@ -1330,9 +1332,10 @@ def handle_file_group_search_results(session: Session, statement: str, params: d
from modules.archive.models import Archive
from modules.videos.models import Video
# Video's `__json__` reads its (deferred) ffprobe_json for the codecs.
# Serialized below: what Video's and Archive's `__json__` read beyond the FileGroup (see their `json_options`).
results = session.query(FileGroup, Video, Archive) \
.options(undefer(Video.ffprobe_json)) \
.options(*FileGroup.json_options(), undefer(Video.ffprobe_json), joinedload(Video.channel),
joinedload(Archive.collection)) \
.filter(FileGroup.id.in_(ordered_ids)) \
.outerjoin(Video, Video.file_group_id == FileGroup.id) \
.outerjoin(Archive, Archive.file_group_id == FileGroup.id)

View file

@ -8,7 +8,7 @@ from typing import List, Type, Optional, Iterable
from sqlalchemy import Column, String, BigInteger, Boolean, event, Index, Integer, JSON, or_
from sqlalchemy import types
from sqlalchemy.orm import deferred, relationship, Session
from sqlalchemy.orm import deferred, relationship, Session, selectinload
from wrolpi.common import Base, ModelHelper, logger, recursive_map, get_media_directory, \
get_relative_to_media_directory, unique_by_predicate, replace_file
@ -121,8 +121,7 @@ class FileGroup(ModelHelper, Base):
# `file_group_effective_datetime_trigger` and `update_effective_datetime` event handler
effective_datetime = Column(TZDateTime) # Equivalent to COALESCE(published_datetime, download_datetime)
# Eager: `__json__` lists the Tags of every FileGroup it serializes.
tag_files: Iterable[TagFile] = relationship('TagFile', cascade='all', lazy='selectin')
tag_files: Iterable[TagFile] = relationship('TagFile', cascade='all')
# Full-text search over these columns is provided by the external-content FTS5 table
# `file_group_fts` (see `wrolpi.fts`): a=title, b=author-ish, c=description, d=body/captions.
@ -135,6 +134,11 @@ class FileGroup(ModelHelper, Base):
m = f'model={self.model}' if self.model else f'mimetype={self.mimetype}'
return f'<FileGroup id={self.id} {m} primary_path={repr(str(self.primary_path))}>'
@staticmethod
def json_options() -> tuple:
"""Loader options for a query whose FileGroups will be serialized: `__json__` reads their Tags."""
return selectinload(FileGroup.tag_files),
@serializer
def __json__(self) -> dict:
from wrolpi.files.lib import split_path_stem_and_suffix

View file

@ -36,6 +36,7 @@ class TagFile(ModelHelper, Base):
created_at: datetime = Column(TZDateTime, default=dates.now)
tag_id = Column(Integer, ForeignKey('tag.id'), primary_key=True)
# Joined: a TagFile is loaded for its Tag's name (serializers, the tags config).
tag = relationship('Tag', back_populates='tag_files', lazy='joined')
file_group_id = Column(BigInteger, ForeignKey('file_group.id', ondelete='CASCADE'), primary_key=True)
file_group = relationship('FileGroup', back_populates='tag_files')

View file

@ -292,3 +292,32 @@ def test_no_db_access_logs_once_and_allows_in_production(test_session, caplog):
assert test_session.query(Tag).count() == 0
warnings = [r for r in caplog.records if 'a production serializer ran SQL' in r.getMessage()]
assert len(warnings) == 1
@pytest.mark.allow_unexpected_queries
def test_no_db_access_reports_the_query_not_the_begin(test_session):
"""On a Session with no open transaction the engine emits BEGIN first; the guard names the real query."""
from wrolpi import db
test_session.commit()
with production_like_sessions(test_session) as maker:
session = maker()
with pytest.raises(UnexpectedQuery, match='SELECT'):
with no_db_access('a serializer on a fresh session'):
session.query(Tag).count()
session.close()
assert [statement.split()[0] for _, statement in db.UNEXPECTED_QUERIES] == ['SELECT']
db.UNEXPECTED_QUERIES.clear()
def test_no_db_access_logs_the_query_in_production(test_session, caplog):
"""The single production warning per serializer names the query that ran, not the transaction's BEGIN."""
test_session.commit()
with production_like_sessions(test_session) as maker, mock.patch('wrolpi.db.PYTEST', False), \
mock.patch('wrolpi.db._warned_serializers', set()), caplog.at_level(logging.WARNING, logger='wrolpi.db'):
session = maker()
with no_db_access('a production serializer'):
session.query(Tag).count()
session.close()
warnings = [r.getMessage() for r in caplog.records if 'a production serializer ran SQL' in r.getMessage()]
assert len(warnings) == 1
assert 'SELECT' in warnings[0] and 'BEGIN' not in warnings[0]

View file

@ -1,10 +1,9 @@
"""Serializers read only data that is already loaded (see `wrolpi.db.no_db_access`).
A lazy load while serializing is a hidden query per row, and a `MissingGreenlet` error under an async Session.
The relationships each serializer reads are loaded with their parent (`lazy='joined'` / `'selectin'`).
A lazy load while serializing is a hidden query per row. Narrow many-to-one relationships load with their parent
(`lazy='joined'`); a query whose results are serialized adds the model's `json_options()` for the rest.
"""
import pytest
from sqlalchemy.orm import undefer
from modules.archive.models import Archive
from modules.docs.models import Doc
@ -47,17 +46,17 @@ async def test_models_serialize_without_queries(async_client, test_session, tag_
with production_like_sessions(test_session) as maker:
session = maker()
for model in (FileGroup, Video, Archive, Doc, Zim, ZimSubscription, Collection, Channel, Tag, Download):
# Each serializing query loads what the model's serializer reads (`json_options`).
query = session.query(model)
if model is Video:
# Like the queries that serialize Videos: the codecs come from the deferred ffprobe_json.
query = query.options(undefer(Video.ffprobe_json))
if hasattr(model, 'json_options'):
query = query.options(*model.json_options())
objects = query.all()
assert objects, f'no {model.__name__} to serialize'
with no_db_access(f'serializing {model.__name__}'):
for obj in objects:
obj.__json__()
items = session.query(CollectionItem).all()
items = session.query(CollectionItem).options(*CollectionItem.json_options()).all()
assert len(items) == 2
with no_db_access('serializing CollectionItem'):
serialized = [item.dict() for item in items]