files: shared modeler batch loop for video, doc, zim, archive (#722)

* files: shared modeler batch loop for video, doc, zim, archive

Paging, skip-ids, progress, and cancel yields live in
wrolpi.files.modeler.run_modeler_loop.  Each module still owns select /
prepare / apply and its transaction style: video probes outside the write
lock, docs use one write per file with lock retries, zim and archive use
one write per page.  Archive HTML that is not a SingleFile raises
SkipModeler so apply_indexers can claim it.

* files: quieter batch-modeler errors; drop unused imports

Break out of a batch page on InvalidRequestError after a flush failure
so the rest of the page does not each log the same poisoned session.
Re-raise PYTEST apply failures without a misleading commit-failure
message.  Remove asyncio/List/OperationalError/PYTEST/Iterable leftovers
from docs, archive, and zim.
This commit is contained in:
lrnselfreliance 2026-09-24 16:47:31 -06:00 • committed by Roland
parent 2d0f433450
commit 92fa3f785f
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
9 changed files with 443 additions and 279 deletions

View file

@ -1,13 +1,11 @@
import asyncio
import json
import os
import pathlib
import tempfile
from abc import ABC
from dataclasses import dataclass
from typing import Callable, List, Optional, Tuple, Iterable
from typing import Callable, List, Optional, Tuple
from sqlalchemy import not_
from sqlalchemy.orm import Session
from wrolpi.cmd import SINGLE_FILE_BIN, CHROMIUM, FIREFOX, DENO_BIN, SINGLE_FILE_DENO_SCRIPT
@ -17,6 +15,7 @@ from wrolpi.common import logger, register_modeler, register_refresh_cleanup, li
from wrolpi.db import get_db_session
from wrolpi.downloader import Downloader, Download, DownloadContext, DownloadResult
from wrolpi.errors import UnrecoverableDownloadError
from wrolpi.files.modeler import run_modeler_loop, skip_clause, SkipModeler
from wrolpi.files.models import FileGroup
from wrolpi.vars import PYTEST, DOCKERIZED, DOWNLOAD_USER_AGENT, PROJECT_DIR
from . import lib
@ -358,85 +357,46 @@ def model_archive(session: Session, file_group: FileGroup) -> Archive:
raise InvalidArchive(f'Failed to model Archive {file_group}') from e
ARCHIVE_PROCESSING_LIMIT = 20
def _select_archive_ids(session, skip_ids: set) -> list[int]:
query = session.query(FileGroup.id).filter(
# Archive shares text/html with generic HTML. Unlike video/doc/zim, do NOT
# select Archive.id IS NULL: that would re-probe every leftover HTML file
# every refresh. Non-SingleFile HTML stays indexed=0 so apply_indexers
# can claim it.
FileGroup.indexed != True,
FileGroup.mimetype == 'text/html',
)
query = skip_clause(query, FileGroup.id, skip_ids)
return [row[0] for row in query.limit(ARCHIVE_PROCESSING_LIMIT).all()]
def _apply_archive(session: Session, file_group: FileGroup, prepared):
with slow_logger(1, f'Modeling archive took %(elapsed)s seconds: {file_group}', logger__=logger):
archive = session.query(Archive).filter_by(file_group_id=file_group.id).one_or_none()
if archive:
archive.validate()
return
try:
model_archive(session, file_group)
except InvalidArchive as e:
raise SkipModeler from e
@register_modeler
async def archive_modeler(progress_callback: Callable[[int], None] = None):
"""Searches DB for FileGroups that contain an HTML file. If the HTML file is a SingleFile, we model it as an
Archive."""
invalid_archives = set()
total_processed = 0
while True:
with get_db_session(commit=True) as session:
results = session.query(FileGroup, Archive) \
.filter(
# Archive shares text/html with generic HTML. Unlike video/doc/zim, do NOT
# select Archive.id IS NULL: that would re-probe every leftover HTML file
# every refresh. Non-SingleFile HTML stays indexed=0 so apply_indexers
# can claim it.
FileGroup.indexed != True,
FileGroup.mimetype == 'text/html',
).filter(not_(FileGroup.id.in_(list(invalid_archives)))) \
.outerjoin(Archive, Archive.file_group_id == FileGroup.id) \
.limit(20)
results: Iterable[Tuple[FileGroup, Archive]]
results_list = list(results)
processed = 0
for processed, (file_group, archive) in enumerate(results_list):
with slow_logger(1, f'Modeling archive took %(elapsed)s seconds: {file_group}',
logger__=logger):
if archive:
try:
archive_id = archive.id
archive.validate()
# Successfully validated, mark as indexed
file_group.indexed = True
except Exception as e:
logger.error(f'Unable to validate Archive {archive_id}')
# Don't mark as indexed - will retry later
if PYTEST:
raise
else:
try:
model_archive(session, file_group)
# Successfully modeled, mark as indexed
file_group.indexed = True
except InvalidArchive:
# It was not a real Archive. Many HTML files will not be an Archive.
file_group.indexed = False
invalid_archives.add(file_group.id)
except Exception as e:
# Some other error occurred during modeling - don't mark as indexed so we can retry
logger.error(f'Failed to model Archive for FileGroup {file_group.id}: {e}')
if PYTEST:
raise
try:
session.commit()
except Exception as e:
# One poisoned row must not kill the modeler: skip this batch for the rest of this
# run and keep modeling. The batch stays unindexed, so the next refresh retries it.
session.rollback()
logger.error(f'archive_modeler failed to commit batch, skipping {len(results_list)} FileGroups',
exc_info=e)
invalid_archives.update(fg.id for fg, _ in results_list)
if PYTEST:
raise
# Report batch progress
batch_count = len(results_list)
total_processed += batch_count
if progress_callback and batch_count > 0:
progress_callback(total_processed)
if processed < 19:
# Did not reach limit (enumerate is 0-indexed, so 19 = 20 items), do not query again.
if __debug__ and logger.isEnabledFor(TRACE_LEVEL):
logger.trace(f'archive_modeler: DONE (processed {processed + 1} files)')
break
# Sleep to catch cancel.
await asyncio.sleep(0)
await run_modeler_loop(
name='archive_modeler',
batch_size=ARCHIVE_PROCESSING_LIMIT,
select_ids=_select_archive_ids,
apply=_apply_archive,
txn='batch',
progress_callback=progress_callback,
)
@register_refresh_cleanup

View file

@ -1,17 +1,16 @@
import asyncio
import logging
from typing import Callable, List
from typing import Callable
from sqlalchemy import or_
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session
from wrolpi.common import register_modeler, register_refresh_cleanup, \
truncate_object_bytes, split_lines_by_length
from wrolpi.db import get_db_session
from wrolpi.files.indexers import Indexer
from wrolpi.files.modeler import run_modeler_loop, skip_clause
from wrolpi.files.models import FileGroup
from wrolpi.vars import PYTEST, FILE_MAX_TEXT_SIZE
from wrolpi.vars import FILE_MAX_TEXT_SIZE
from .extractors import extract_metadata
from .lib import get_or_create_subject_collection, get_or_create_author_collection, is_valid_author, split_authors, \
normalize_author, normalize_subject, split_subjects, is_valid_subject, discover_calibre_cover
@ -24,6 +23,33 @@ DOC_PROCESSING_LIMIT = 10
DOC_LOCK_RETRIES = 3
def _select_doc_ids(session, skip_ids: set) -> list[int]:
query = session.query(FileGroup.id) \
.outerjoin(Doc, Doc.file_group_id == FileGroup.id) \
.filter(
or_(
*[FileGroup.mimetype == mt for mt in DOC_MIMETYPES],
*[FileGroup.primary_path.like(f'%{suffix}') for suffix in COMIC_BOOK_SUFFIXES],
),
# Model any doc that has no Doc row yet (even if `apply_indexers` already set
# `indexed=True` before this modeler ran), or any doc explicitly flagged for
# re-indexing. Gating solely on `indexed == False` left already-indexed docs
# permanently unmodeled and invisible in /api/docs.
or_(
Doc.id.is_(None),
FileGroup.indexed != True,
),
)
query = skip_clause(query, FileGroup.id, skip_ids)
return [row[0] for row in query.limit(DOC_PROCESSING_LIMIT).all()]
def _apply_doc(session: Session, file_group: FileGroup, prepared):
doc = _model_doc(file_group, session)
session.add(doc)
file_group.model = Doc.__tablename__
@register_modeler
async def doc_modeler(progress_callback: Callable[[int], None] = None):
"""Searches for doc files (epub, mobi, pdf, docx, doc, odt, cbz, cbr) and models them.
@ -36,78 +62,15 @@ async def doc_modeler(progress_callback: Callable[[int], None] = None):
modeler run. Per-doc transactions keep the write window short and isolate failures so one
locked/broken doc cannot poison the rest of the run.
"""
total_processed = 0
# Ids of docs that failed to model this run. A failed doc keeps matching the `Doc.id IS NULL`
# gate below, so without excluding it the loop would re-select it forever, hang, and starve
# later docs. Successfully-modeled docs are excluded automatically (they gain a Doc row), so
# only failures need tracking -- keeping this set (and the `notin_` clause) small. Failed docs
# are retried on the next refresh.
failed_ids: set = set()
while True:
# Short read-only transaction to choose the next batch of ids. Do NOT hold it open across
# the slow per-doc modeling below.
with get_db_session() as session:
query = session.query(FileGroup.id) \
.outerjoin(Doc, Doc.file_group_id == FileGroup.id) \
.filter(
or_(
*[FileGroup.mimetype == mt for mt in DOC_MIMETYPES],
*[FileGroup.primary_path.like(f'%{suffix}') for suffix in COMIC_BOOK_SUFFIXES],
),
# Model any doc that has no Doc row yet (even if `apply_indexers` already set
# `indexed=True` before this modeler ran), or any doc explicitly flagged for
# re-indexing. Gating solely on `indexed == False` left already-indexed docs
# permanently unmodeled and invisible in /api/docs.
or_(
Doc.id.is_(None),
FileGroup.indexed != True,
),
)
if failed_ids:
query = query.filter(FileGroup.id.notin_(failed_ids))
fg_ids: List[int] = [row[0] for row in query.limit(DOC_PROCESSING_LIMIT).all()]
if not fg_ids:
break
for fg_id in fg_ids:
for attempt in range(DOC_LOCK_RETRIES):
try:
# One short write transaction per doc.
with get_db_session(commit=True) as session:
file_group = session.query(FileGroup).get(fg_id)
if file_group is None:
# Deleted between discovery and now; nothing to model.
break
doc = _model_doc(file_group, session)
session.add(doc)
file_group.model = Doc.__tablename__
file_group.indexed = True
break
except OperationalError as e:
# Another connection committed during our transaction. Retrying with a fresh
# transaction gets a fresh snapshot and usually succeeds.
if 'locked' in str(e).lower() and attempt < DOC_LOCK_RETRIES - 1:
await asyncio.sleep(0.2 * (attempt + 1))
continue
logger.error(f'Failed to model doc file_group_id={fg_id}: database error', exc_info=e)
failed_ids.add(fg_id)
break
except Exception as e:
# Never reference the ORM object here: after a failed flush the session is
# rolled back, so touching a lazy/expired attribute (e.g. in its repr) raises a
# second exception that would abort the whole modeler. Log the plain id only.
logger.error(f'Failed to model doc file_group_id={fg_id}', exc_info=e)
failed_ids.add(fg_id)
if PYTEST:
raise
break
# Yield so a cancel/other tasks can run between docs.
await asyncio.sleep(0)
total_processed += len(fg_ids)
if progress_callback:
progress_callback(total_processed)
await run_modeler_loop(
name='doc_modeler',
batch_size=DOC_PROCESSING_LIMIT,
select_ids=_select_doc_ids,
apply=_apply_doc,
txn='per_item',
lock_retries=DOC_LOCK_RETRIES,
progress_callback=progress_callback,
)
def _model_doc(file_group: FileGroup, session: Session) -> Doc:

View file

@ -167,7 +167,7 @@ async def test_doc_modeler_terminates_on_persistent_failure(async_client, test_s
# PYTEST re-raises inside the loop's per-item handler; disable that so we exercise the failure
# path and the loop's termination guard, not the re-raise.
with patch('modules.docs._model_doc', side_effect=RuntimeError('boom')), \
patch('modules.docs.PYTEST', False):
patch('wrolpi.files.modeler.PYTEST', False):
await asyncio.wait_for(doc_modeler(), timeout=10)
# No Doc rows were created (every attempt failed), but the loop still terminated.

View file

@ -10,6 +10,7 @@ from modules.videos.common import get_or_create_ffprobe_json
from modules.videos.models import Video, AUDIO_PLAYLIST_MIMETYPES
from wrolpi.common import logger, limit_concurrent, register_modeler, register_refresh_cleanup
from wrolpi.db import get_db_curs, get_db_session
from wrolpi.files.modeler import run_modeler_loop, skip_clause
from wrolpi.files.models import FileGroup
from wrolpi.vars import PYTEST
from .downloader import video_downloader # Import downloaders so they are registered.
@ -43,76 +44,55 @@ def _model_video(session: Session, file_group: FileGroup, video: Video | None, p
return video
def _select_video_ids(session, skip_ids: set) -> list[int]:
query = session.query(FileGroup.id) \
.outerjoin(Video, Video.file_group_id == FileGroup.id) \
.filter(
or_(FileGroup.mimetype.like('video/%'), FileGroup.mimetype.like('audio/%')),
FileGroup.mimetype.notin_(AUDIO_PLAYLIST_MIMETYPES),
# Exclusive-mimetype modeler: claim unmodeled rows even if apply_indexers
# already set indexed=True, and re-model when files changed (indexed != True).
or_(Video.id.is_(None), FileGroup.indexed != True),
)
query = skip_clause(query, FileGroup.id, skip_ids)
return [row[0] for row in query.limit(VIDEO_PROCESSING_LIMIT).all()]
async def _prepare_videos(ids: list[int]) -> dict:
"""ffprobe with no write transaction open. A subprocess per video can run for seconds."""
with get_db_session() as session:
rows = session.query(FileGroup.id, FileGroup.primary_path).filter(FileGroup.id.in_(ids)).all()
probed = dict()
for file_group_id, primary_path in rows:
try:
probed[file_group_id] = await get_or_create_ffprobe_json(pathlib.Path(str(primary_path)))
except Exception as e:
if PYTEST:
raise
logger.error(f'Unable to ffprobe Video: {primary_path}', exc_info=e)
await asyncio.sleep(0)
return probed
def _apply_video(session: Session, file_group: FileGroup, prepared: dict):
video = session.query(Video).filter_by(file_group_id=file_group.id).one_or_none()
_model_video(session, file_group, video, prepared or {})
@register_modeler
async def video_modeler(progress_callback: Callable[[int], None] = None):
total_processed = 0
# Ids that failed to model this run. A failure leaves no Video row, so the `Video.id IS
# NULL` gate below would re-select it forever; failures are retried on the next refresh.
failed_ids: set = set()
while True:
# Read the batch; nothing is claimed yet, so the write lock stays free while ffprobe runs.
with get_db_session() as session:
query = session.query(FileGroup.id, FileGroup.primary_path) \
.outerjoin(Video, Video.file_group_id == FileGroup.id) \
.filter(
or_(FileGroup.mimetype.like('video/%'), FileGroup.mimetype.like('audio/%')),
FileGroup.mimetype.notin_(AUDIO_PLAYLIST_MIMETYPES),
# Exclusive-mimetype modeler: claim unmodeled rows even if apply_indexers
# already set indexed=True, and re-model when files changed (indexed != True).
or_(Video.id.is_(None), FileGroup.indexed != True),
)
if failed_ids:
query = query.filter(FileGroup.id.notin_(failed_ids))
batch: List[Tuple[int, pathlib.Path]] = list(query.limit(VIDEO_PROCESSING_LIMIT).all())
if not batch:
break
# ffprobe each file with no transaction open. This is a subprocess per video and can run
# for seconds; inside the write transaction below it would hold the write lock for the whole
# batch, and every other writer on the box would wait out `busy_timeout` (30s).
probed = dict()
for file_group_id, primary_path in batch:
try:
probed[file_group_id] = await get_or_create_ffprobe_json(pathlib.Path(str(primary_path)))
except Exception as e:
if PYTEST:
raise
logger.error(f'Unable to ffprobe Video: {primary_path}', exc_info=e)
# Sleep to catch cancel.
await asyncio.sleep(0)
with get_db_session(commit=True) as session:
file_groups: List[Tuple[FileGroup, Video]] = list(
session.query(FileGroup, Video)
.filter(FileGroup.id.in_([i for i, _ in batch]))
.outerjoin(Video, Video.file_group_id == FileGroup.id))
for file_group, video in file_groups:
try:
_model_video(session, file_group, video, probed)
except Exception as e:
# Before the re-raise, so no failure path can re-select this id forever.
failed_ids.add(file_group.id)
if PYTEST:
raise
logger.error(f'Unable to model Video: {file_group.primary_path}', exc_info=e)
file_group.indexed = True
# Report batch progress
total_processed += len(batch)
if progress_callback:
progress_callback(total_processed)
logger.debug(f'Modeled {len(batch)} videos')
if len(batch) < VIDEO_PROCESSING_LIMIT:
# Did not reach limit, do not query again.
break
# Sleep to catch cancel.
await asyncio.sleep(0)
await run_modeler_loop(
name='video_modeler',
batch_size=VIDEO_PROCESSING_LIMIT,
select_ids=_select_video_ids,
prepare=_prepare_videos,
apply=_apply_video,
txn='batch',
# A failure leaves no Video row, so Video.id IS NULL would re-select it. Mark indexed
# anyway; the skip set covers this run, the next refresh retries via Video.id IS NULL.
mark_indexed_on_failure=True,
progress_callback=progress_callback,
)
# Rows written per transaction when claiming Videos for their Channels. Small enough that the

View file

@ -163,7 +163,7 @@ async def test_video_modeler_terminates_on_persistent_failure(async_client, test
# loop's per-item handler; disable that to exercise the failure path and the termination
# guard, not the re-raise.
with patch('modules.videos._model_video', side_effect=RuntimeError('boom')), \
patch('modules.videos.PYTEST', False):
patch('wrolpi.files.modeler.PYTEST', False):
await asyncio.wait_for(video_modeler(), timeout=10)
assert test_session.query(Video).count() == 0, 'every attempt failed, yet the loop terminated'

View file

@ -1,4 +1,3 @@
import asyncio
import functools
import json
import pathlib
@ -26,6 +25,7 @@ from wrolpi.events import Events
from wrolpi.db import get_db_session, get_db_curs
from wrolpi.downloader import DownloadFrequency
from wrolpi.files.lib import split_file_name_words
from wrolpi.files.modeler import run_modeler_loop, skip_clause
from wrolpi.files.worker import file_worker
from wrolpi.files.models import FileGroup
from wrolpi.vars import PYTEST, DOCKERIZED
@ -73,58 +73,43 @@ def model_zim(file_group: FileGroup, session: Session) -> Zim:
return zim
ZIM_PROCESSING_LIMIT = 10
def _select_zim_ids(session, skip_ids: set) -> list[int]:
query = session.query(FileGroup.id) \
.outerjoin(Zim, Zim.file_group_id == FileGroup.id) \
.filter(FileGroup.primary_path.ilike('%.zim')) \
.filter(
# Exclusive-mimetype modeler: claim unmodeled rows even if apply_indexers
# already set indexed=True, and re-model when files changed.
or_(Zim.id.is_(None), FileGroup.indexed != True),
)
query = skip_clause(query, FileGroup.id, skip_ids)
return [row[0] for row in query.limit(ZIM_PROCESSING_LIMIT).all()]
def _apply_zim(session: Session, file_group: FileGroup, prepared):
zim = session.query(Zim).filter_by(file_group_id=file_group.id).one_or_none()
if not zim:
zim = model_zim(file_group, session)
zim.flush()
else:
file_group.title = file_group.primary_path.name
file_group.a_text = split_file_name_words(file_group.primary_path.name)
file_group.model = 'zim'
@register_modeler
async def zim_modeler(progress_callback: Callable[[int], None] = None):
total_processed = 0
failed_ids: set = set()
while True:
with get_db_session(commit=True) as session:
query = session.query(FileGroup, Zim) \
.outerjoin(Zim, Zim.file_group_id == FileGroup.id) \
.filter(FileGroup.primary_path.ilike('%.zim')) \
.filter(
# Exclusive-mimetype modeler: claim unmodeled rows even if apply_indexers
# already set indexed=True, and re-model when files changed.
or_(Zim.id.is_(None), FileGroup.indexed != True),
)
if failed_ids:
query = query.filter(FileGroup.id.notin_(failed_ids))
file_groups: List[Tuple[FileGroup, Zim]] = list(query.limit(10))
processed = 0
for file_group, zim in file_groups:
processed += 1
zim_id = None
try:
if not zim:
zim = model_zim(file_group, session)
zim.flush()
else:
zim_id = zim.id
file_group.title = file_group.primary_path.name
file_group.a_text = split_file_name_words(file_group.primary_path.name)
file_group.indexed = True
file_group.model = 'zim'
except Exception as e:
failed_ids.add(file_group.id)
if PYTEST:
raise
logger.error(f'Unable to model Zim {zim_id=} {file_group.primary_path=}', exc_info=e)
logger.debug(f'Modeled {processed} zim files')
# Report batch progress
total_processed += len(file_groups)
if progress_callback and len(file_groups) > 0:
progress_callback(total_processed)
if processed < 10:
# Did not reach limit, do not query again.
break
# Sleep to catch cancel.
await asyncio.sleep(0)
await run_modeler_loop(
name='zim_modeler',
batch_size=ZIM_PROCESSING_LIMIT,
select_ids=_select_zim_ids,
apply=_apply_zim,
txn='batch',
progress_callback=progress_callback,
)
def get_all_entries_tags():

View file

@ -8,14 +8,11 @@ import shutil
import pytest
from modules.zim.lib import zim_modeler
from modules.zim.lib import zim_modeler, ZIM_PROCESSING_LIMIT
from modules.zim.models import Zim
from wrolpi.files.models import FileGroup
from wrolpi.vars import PROJECT_DIR
# The zim_modeler uses a hardcoded limit of 10
ZIM_PROCESSING_LIMIT = 10
@pytest.mark.asyncio
async def test_zim_modeler_processes_more_than_batch_limit(async_client, test_session, test_directory):