Make doc_modeler resilient to SQLite "database is locked"

A refresh on a large library aborted the whole doc_modeler run on the
first lock error, so the ~20k stranded docs never got modeled.

doc_modeler held one write transaction across a whole batch of 10 docs
while doing slow per-doc extraction (a PDF/epub can be tens of MB). When
another connection committed mid-batch, the deferred transaction failed
to upgrade to a write with SQLITE_BUSY_SNAPSHOT ("database is locked",
which busy_timeout does not absorb -- the same trap that motivated the
short-transaction rewrite of apply_indexers). The per-item error handler
then did `logger.error(f'... {file_group}')`, which lazy-loaded an
attribute on the now-rolled-back session and raised a *second* exception,
escaping the handler and aborting the entire modeler.

Restructure:
- Pick each batch of candidate ids in a short read-only transaction, then
  model each doc in its own short write transaction. A lock/error on one
  doc rolls back only that doc and cannot poison the rest of the run.
- Retry a doc a few times on "database is locked" -- a fresh transaction
  gets a fresh snapshot and succeeds.
- Log the plain file_group id on failure; never touch the ORM object on a
  possibly rolled-back session.
- Track only *failed* ids for the termination guard (successes are
  excluded by the Doc.id IS NULL gate), keeping the notin_ set small and
  well under SQLite's bound-variable limit.

Adds test_doc_modeler_survives_database_locked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-20 13:44:27 -06:00
parent 80cc758e6c
commit 9310fe801f
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
2 changed files with 100 additions and 34 deletions

View file

@ -1,10 +1,12 @@
"""Tests for the doc_modeler."""
import asyncio
import shutil
import sqlite3
from unittest.mock import patch
import pytest
from PIL import Image
from sqlalchemy.exc import OperationalError
from modules.docs import doc_modeler
from modules.docs.models import Doc, DocSection
@ -173,6 +175,41 @@ async def test_doc_modeler_terminates_on_persistent_failure(async_client, test_s
assert test_session.query(Doc).count() == 0
@pytest.mark.asyncio
async def test_doc_modeler_survives_database_locked(async_client, test_session, test_directory, refresh_files):
"""A transient SQLite "database is locked" on one doc must not abort the whole modeler run.
Regression: doc_modeler held one write transaction across a whole batch of slow extractions.
When another connection committed mid-batch, the deferred transaction failed to upgrade with
SQLITE_BUSY_SNAPSHOT ("database is locked"), the error handler crashed on the rolled-back
session, and the entire run aborted -- so no further docs were modeled. Each doc now runs in
its own short transaction and a lock is retried.
"""
ebook_dir = test_directory / 'ebooks'
ebook_dir.mkdir(parents=True)
for i in range(3):
shutil.copy(PROJECT_DIR / 'test/ebook example.epub', ebook_dir / f'book_{i:03d}.epub')
from modules.docs import _model_doc as real_model_doc
calls = {'n': 0}
def flaky_model_doc(file_group, session):
# Fail the very first modeling attempt with a lock error, then behave normally. Proves the
# run recovers (retries the doc, keeps modeling the rest) instead of aborting.
calls['n'] += 1
if calls['n'] == 1:
raise OperationalError('stmt', {}, sqlite3.OperationalError('database is locked'))
return real_model_doc(file_group, session)
with patch('modules.docs._model_doc', side_effect=flaky_model_doc):
await refresh_files()
# All three ebooks were modeled despite the initial lock error.
assert test_session.query(Doc).count() == 3
assert calls['n'] >= 4 # 1 failed attempt + 1 retry + 2 more docs.
@pytest.mark.asyncio
async def test_doc_modeler_processes_more_than_batch_limit(async_client, test_session, test_directory):
"""Doc modeler processes more than one batch of files (limit=10)."""