db: write intent is the opt-in, not a second session helper

Three "database is locked" failures on the docker WROLPi, one cause each:

  * `INSERT INTO download` from POST /api/download -- a user creating a
    video download while a refresh ran (2026-08-06 14:06:46);
  * six `INSERT INTO file_group` from `upsert_file`, uploads landing in
    the same three-minute window;
  * `UPDATE download SET status` from `renew_recurring_downloads`.

All three are the deferred read-then-write trap: a transaction that
begins as a reader and upgrades at its first write is refused
*instantly* when another connection wrote in between, because
busy_timeout is skipped for lock upgrades.  `get_immediate_db_session`
existed to avoid it, but an opt-in helper only works if every author
remembers, and read-then-write is the ordinary shape of a write.  So it
is gone: `get_db_session(commit=True)` -- the existing declaration of
write intent -- now begins IMMEDIATE.  Rails 7.1 and Django 5.1 made the
same call, defaulting SQLite transactions to IMMEDIATE rather than
asking application code to opt in.

That default is only safe if write transactions stay short, and one did
not.  `video_cleanup` ran its whole Channel-claiming join --
`UPDATE video ... FROM channel ... LEFT JOIN file_group`, O(channels x
file_groups) -- inside one write transaction: 110 to 130 seconds each of
the eight times it appears in the log, on a library with ~1500 channels.
While it ran, *every* writer on the box failed, including ones that
began IMMEDIATE, because the hold far exceeds the 30s busy_timeout.  The
14:04-14:06 failures above sit inside its 130-second window.  The join
is now a read -- WAL readers never block writers -- and only the
resulting UPDATEs take the lock, 500 rows per transaction.
`claim_videos_for_channels` had the same join and the same fix; channel
import calls it with every channel in channels.yaml.

Also here:

  * `renew_recurring_downloads` was a read session with an inner commit;
    it is a write session now.  Its computed `next_download` values now
    persist even when nothing was renewed -- that is the point of
    computing them.
  * POST /api/download no longer writes through `request.ctx.session`.
    That session is committed by the response middleware, so a writer
    would hold the lock across the whole request; a write session
    commits before the handler returns, ahead of the background dispatch
    it fires.
  * `_immediate_txn` is a ContextVar, not a threading.local.  The
    writers that set it include coroutines, and one asyncio thread runs
    many of them interleaved.
  * `write_lock_held_briefly` moved from the move tests into conftest,
    since two suites now need it.

Both regression tests were verified by mutation: reverting either site
alone fails its own test, and the API one fails with the production
error verbatim.

Sources: fractaledmind.com/2023/12/11/sqlite-on-rails-improving-concurrency,
tenthousandmeters.com/blog/sqlite-concurrent-writes-and-database-is-locked-errors,
berthub.eu/articles/posts/a-brief-post-on-sqlite3-database-locked-despite-timeout

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roland 2026-08-07 19:40:41 -06:00 • committed by lrnselfreliance
parent 457df5f31a
commit daf2ba39d4
12 changed files with 293 additions and 129 deletions

View file

@ -76,38 +76,98 @@ async def video_modeler(progress_callback: Callable[[int], None] = None):
await asyncio.sleep(0) await asyncio.sleep(0)
# Rows written per transaction when claiming Videos for their Channels. Small enough that the
# write lock is held for milliseconds at a time; large enough that a big library does not pay a
# transaction per row.
CLAIM_CHUNK_SIZE = 500
def _chunks(items: list, size: int = CLAIM_CHUNK_SIZE):
for start in range(0, len(items), size):
yield items[start:start + size]
def _first_claim_per_video(rows) -> List[Tuple[int, int]]:
"""One `(video_id, channel_id)` per Video.
A Video nested under two Channel directories matches both; the single `UPDATE ... FROM` this
replaced also picked one arbitrarily, so keep the first row and drop the rest rather than
writing the same Video twice."""
claims = dict()
for video_id, channel_id in rows:
claims.setdefault(video_id, channel_id)
return list(claims.items())
def _claim_videos(claims: List[Tuple[int, int]]):
"""Assign `(video_id, channel_id)` pairs, a chunk per write transaction.
The pairs are computed by a *read* — WAL readers never block writers — and only these short
UPDATEs take the write lock. Doing the whole thing as one `UPDATE ... FROM channel` held the
lock for the entire join: 110-130 seconds per refresh on a library with ~1500 channels, which
is well past the 30s `busy_timeout`, so every other writer on the box failed with "database is
locked" for two solid minutes.
`channel_id IS NULL` is re-checked in the UPDATE because the read ran in an earlier
transaction: a download may have claimed the Video since, and it should win.
"""
if not claims:
return
claimed = 0
for chunk in _chunks(claims):
with get_db_curs(commit=True) as curs:
curs.executemany('UPDATE video SET channel_id = ? WHERE id = ? AND channel_id IS NULL',
[(channel_id, video_id) for video_id, channel_id in chunk])
claimed += len(chunk)
logger.debug(f'Claimed {claimed} Videos for their Channels')
# Videos in a Channel's directory (or below it) that no Channel has claimed yet.
_UNCLAIMED_VIDEOS_SQL = '''
SELECT v.id, c.id
FROM video v
INNER JOIN file_group fg ON fg.id = v.file_group_id
INNER JOIN collection col ON fg.directory = col.directory
OR fg.directory LIKE col.directory || '/%'
INNER JOIN channel c ON c.collection_id = col.id
WHERE v.channel_id IS NULL
'''
@register_refresh_cleanup @register_refresh_cleanup
@limit_concurrent(1) @limit_concurrent(1)
def video_cleanup(): def video_cleanup():
logger.info('Claiming Videos for their Channels') logger.info('Claiming Videos for their Channels')
with get_db_curs(commit=True) as curs: # Read the FileGroups that are no longer video/audio, then unmodel them in short transactions.
# Delete all Videos if the FileModel no longer contains a video or audio file. with get_db_curs() as curs:
curs.execute(''' curs.execute('''
UPDATE file_group SELECT id
SET model = NULL FROM file_group
WHERE model = 'video' WHERE model = 'video'
AND mimetype NOT LIKE 'video/%' AND mimetype NOT LIKE 'video/%'
AND mimetype NOT LIKE 'audio/%' AND mimetype NOT LIKE 'audio/%'
RETURNING id
''') ''')
deleted_ids = [i['id'] for i in curs.fetchall()] stale_ids = [i['id'] for i in curs.fetchall()]
if deleted_ids: for chunk in _chunks(stale_ids):
ids = json.dumps(chunk)
with get_db_curs(commit=True) as curs:
curs.execute('''
UPDATE file_group
SET model = NULL
WHERE id IN (SELECT value FROM json_each(:ids))
''', dict(ids=ids))
curs.execute(''' curs.execute('''
DELETE DELETE
FROM video FROM video
WHERE file_group_id IN (SELECT value FROM json_each(:ids)) WHERE file_group_id IN (SELECT value FROM json_each(:ids))
''', dict(ids=json.dumps(deleted_ids))) ''', dict(ids=ids))
# Claim all Videos in a Channel's directory for that Channel. But, only if they have not yet been claimed.
curs.execute(''' # Claim all Videos in a Channel's directory for that Channel. But, only if they have not yet
UPDATE video AS v # been claimed. The join is the slow part, so it runs as a read; only the writes take the lock.
SET channel_id = c.id with get_db_curs() as curs:
FROM channel c curs.execute(_UNCLAIMED_VIDEOS_SQL)
INNER JOIN collection col ON col.id = c.collection_id claims = _first_claim_per_video(curs.fetchall())
LEFT JOIN file_group fg ON fg.directory = col.directory _claim_videos(claims)
OR fg.directory LIKE col.directory || '/%'
WHERE v.channel_id IS NULL
AND fg.id = v.file_group_id
''')
@ -126,15 +186,13 @@ def claim_videos_for_channels(channel_ids: List[int]):
return return
logger.info(f'Claiming Videos for {len(channel_ids)} channel(s)') logger.info(f'Claiming Videos for {len(channel_ids)} channel(s)')
with get_db_curs(commit=True) as curs: # Read the claims, then write them in chunks -- the join must not hold the write lock (see
curs.execute(''' # `_claim_videos`). Channel import calls this with every channel in channels.yaml, so it is
UPDATE video AS v # just as slow as the full `video_cleanup` sweep.
SET channel_id = c.id with get_db_curs() as curs:
FROM channel c curs.execute(f'''
INNER JOIN collection col ON col.id = c.collection_id {_UNCLAIMED_VIDEOS_SQL}
LEFT JOIN file_group fg ON fg.directory = col.directory
OR fg.directory LIKE col.directory || '/%'
WHERE v.channel_id IS NULL
AND fg.id = v.file_group_id
AND c.id IN (SELECT value FROM json_each(:channel_ids)) AND c.id IN (SELECT value FROM json_each(:channel_ids))
''', dict(channel_ids=json.dumps(list(channel_ids)))) ''', dict(channel_ids=json.dumps(list(channel_ids))))
claims = _first_claim_per_video(curs.fetchall())
_claim_videos(claims)

View file

@ -14,6 +14,9 @@ These tests use the REAL modeling flow:
4. Verify the video is correctly associated with the channel 4. Verify the video is correctly associated with the channel
""" """
import contextlib
from unittest import mock
import pytest import pytest
from modules.videos.models import Channel, Video from modules.videos.models import Channel, Video
@ -810,3 +813,71 @@ class TestVideoCleanupWithYearSubdirs:
# video_cleanup should claim the video via LIKE clause # video_cleanup should claim the video via LIKE clause
assert video.channel_id == channel.id, \ assert video.channel_id == channel.id, \
f'video_cleanup should claim video in year subdir, got channel_id={video.channel_id}' f'video_cleanup should claim video in year subdir, got channel_id={video.channel_id}'
@pytest.mark.asyncio
async def test_video_cleanup_claims_outside_a_write_transaction(async_client, test_session, test_directory,
video_file, refresh_files):
"""The Channel-claiming join must not run inside the write transaction.
Regression test for two minutes of global write starvation: `video_cleanup` ran the whole
`UPDATE video ... FROM channel ... LEFT JOIN file_group` in one `BEGIN IMMEDIATE` transaction.
The join is O(channels x file_groups), and on a library with ~1500 channels it held the write
lock for 110-130 seconds per refresh -- well past the 30s busy_timeout, so *every* other writer
failed with "database is locked", including correctly-written ones. The join must be a read;
only the resulting UPDATEs may take the lock.
"""
from modules.videos import video_cleanup, _UNCLAIMED_VIDEOS_SQL
from wrolpi.files import worker as worker_module # noqa: F401 (import parity with other tests)
import modules.videos as videos_module
channel_dir = test_directory / 'videos' / 'LockChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
collection = Collection(name='LockChannel', kind='channel', directory=channel_dir)
test_session.add(collection)
test_session.flush([collection])
channel = Channel(collection_id=collection.id, url='https://example.com/lockchannel')
test_session.add(channel)
test_session.commit()
video_file.rename(channel_dir / 'A Video.mp4')
await refresh_files()
video = test_session.query(Video).one()
video.channel_id = None
test_session.commit()
# Record whether each statement ran in a write transaction (`get_db_curs(commit=True)`).
executed = []
real_get_db_curs = videos_module.get_db_curs
@contextlib.contextmanager
def recording_get_db_curs(commit: bool = False):
with real_get_db_curs(commit=commit) as curs:
class _Recorder:
def __getattr__(self, name):
return getattr(curs, name)
def execute(self, statement, *args, **kwargs):
executed.append((commit, statement))
return curs.execute(statement, *args, **kwargs)
def executemany(self, statement, *args, **kwargs):
executed.append((commit, statement))
return curs.executemany(statement, *args, **kwargs)
yield _Recorder()
with mock.patch.object(videos_module, 'get_db_curs', recording_get_db_curs):
video_cleanup()
test_session.expire_all()
assert test_session.query(Video).one().channel_id == channel.id, 'the Video was not claimed'
join_statements = [(commit, s) for commit, s in executed if 'FROM video v' in s]
assert join_statements, 'the claim join did not run'
assert not any(commit for commit, _ in join_statements), \
'the Channel-claiming join ran inside a write transaction, holding the lock across it'
assert any(commit for commit, s in executed if s.startswith('UPDATE video SET channel_id')), \
'the claims were not written'
assert _UNCLAIMED_VIDEOS_SQL.strip().startswith('SELECT'), 'the claim query must be a read'

View file

@ -128,7 +128,14 @@ api_app.error_handler.add(Exception, json_error_handler)
@api_app.middleware('request') @api_app.middleware('request')
async def inject_session(request: Request): async def inject_session(request: Request):
"""Inject a database session into the request context.""" """Inject a database session into the request context.
This session is deliberately *deferred*, even for a POST: it is committed by the response
middleware, so a writer would hold the SQLite write lock for the whole handler -- including the
slow parts (a chunk being written to disk, a subprocess) and including any background task the
handler fires before returning. A handler that writes should use `get_db_session(commit=True)`,
which begins as a writer and commits before the handler returns.
"""
from wrolpi.db import get_db_context from wrolpi.db import get_db_context
engine, session = get_db_context() engine, session = get_db_context()
request.ctx.session = session request.ctx.session = session

View file

@ -10,6 +10,7 @@ import logging
import multiprocessing import multiprocessing
import pathlib import pathlib
import shutil import shutil
import sqlite3
import tempfile import tempfile
import threading import threading
import time import time
@ -399,6 +400,36 @@ def make_test_ctx(*, is_cancelled=None, can_download=None,
) )
@contextlib.contextmanager
def write_lock_held_briefly(db_file: str, seconds: float = 1.0):
"""Hold the SQLite write lock on `db_file` for `seconds`, from another connection.
Simulates the concurrent writer (refresh, download, config save) that a real WROLPi runs
alongside whatever the test is doing. The lock is released while the test's work is still in
flight, so a correctly-written transaction waits (busy_timeout) and then succeeds; a deferred
one that upgrades its lock mid-transaction fails instantly instead.
Pair with `production_like_sessions` — the shared `test_session` uses one connection, which
cannot contend with itself.
"""
holder = sqlite3.connect(db_file, timeout=30, check_same_thread=False)
holder.isolation_level = None
holder.execute('PRAGMA busy_timeout=30000')
holder.execute('BEGIN IMMEDIATE')
def release():
time.sleep(seconds)
holder.execute('ROLLBACK')
holder.close()
thread = threading.Thread(target=release, daemon=True)
thread.start()
try:
yield
finally:
thread.join(timeout=30)
@contextlib.contextmanager @contextlib.contextmanager
def production_like_sessions(test_session) -> Generator[sessionmaker, Any, None]: def production_like_sessions(test_session) -> Generator[sessionmaker, Any, None]:
"""Patch get_db_session to hand out a *fresh* Session per call, like production. """Patch get_db_session to hand out a *fresh* Session per call, like production.

View file

@ -7,6 +7,7 @@ Engines are created lazily (the media directory must be known first) and use Nul
fresh session per use. Every connection gets the same PRAGMAs (WAL, busy_timeout, foreign keys) fresh session per use. Every connection gets the same PRAGMAs (WAL, busy_timeout, foreign keys)
via `create_wrolpi_engine` — use that factory for any engine touching a WROLPi database. via `create_wrolpi_engine` — use that factory for any engine touching a WROLPi database.
""" """
import contextvars
import pathlib import pathlib
import sqlite3 import sqlite3
import threading import threading
@ -25,9 +26,13 @@ from wrolpi.vars import PYTEST
logger = logger.getChild(__name__) logger = logger.getChild(__name__)
# Toggled only by `get_immediate_db_session()` (below) while such a session is open on this thread, # Set by `get_db_session(commit=True)` while its transaction is being opened, so the engine's `begin`
# so the engine's `begin` listener knows to emit `BEGIN IMMEDIATE` instead of a deferred `BEGIN`. # listener knows to emit `BEGIN IMMEDIATE` instead of a deferred `BEGIN`. A ContextVar rather than a
_immediate_txn = threading.local() # `threading.local` because the writers that set it include coroutines: Sanic runs every request in
# its own asyncio task on one shared thread, and a thread-local would leak the flag between
# interleaved tasks -- arming it for a reader and disarming it under a writer. Each asyncio task
# (and each thread) gets its own copy of a ContextVar, so worker-thread callers behave as before.
_immediate_txn = contextvars.ContextVar('wrolpi_immediate_txn', default=False)
def _adapt_datetime(value): def _adapt_datetime(value):
@ -128,9 +133,9 @@ def create_wrolpi_engine(target: Union[str, pathlib.Path]) -> sqlalchemy.engine.
@event.listens_for(engine, 'begin') @event.listens_for(engine, 'begin')
def _sqlite_do_begin(conn): def _sqlite_do_begin(conn):
# `get_immediate_db_session()` sessions take the write lock up front; everything else stays # Write sessions (`commit=True`) take the write lock up front; read sessions stay deferred
# deferred so readers never block (in WAL; see `_immediate_txn`). # so readers never block (in WAL; see `_immediate_txn`).
if getattr(_immediate_txn, 'active', False): if _immediate_txn.get():
conn.execute('BEGIN IMMEDIATE') conn.execute('BEGIN IMMEDIATE')
else: else:
conn.execute('BEGIN') conn.execute('BEGIN')
@ -184,8 +189,22 @@ def get_db_context() -> Tuple[sqlalchemy.engine.Engine, Session]:
def get_db_session(commit: bool = False) -> Generator[Session, Any, None]: def get_db_session(commit: bool = False) -> Generator[Session, Any, None]:
""" """
Context manager that creates a DB session. This will automatically rollback changes, unless `commit` is True. Context manager that creates a DB session. This will automatically rollback changes, unless `commit` is True.
`commit=True` declares write intent, so the transaction begins as a writer (`BEGIN IMMEDIATE`)
rather than deferred. A deferred transaction that upgrades to a writer at its first INSERT
fails *instantly* with "database is locked" if any other connection wrote in the meantime —
SQLite skips `busy_timeout` for lock upgrades, because waiting cannot help a snapshot that is
already stale. Taking the lock at BEGIN lets `busy_timeout` (30s) absorb the contention
instead. Rails and Django both made IMMEDIATE the default for the same reason; a per-call-site
opt-in only works if every author remembers, and read-then-write is the normal shape of a write.
The cost is that writers serialize from BEGIN rather than from their first write, so a write
transaction must stay short — do the slow parts (globbing, ffprobe, indexing, big joins) in a
read session or outside a session, then write what you computed. Readers are unaffected: WAL
readers never block, and `commit=False` sessions stay deferred.
""" """
_, session = get_db_context() _, session = get_db_context()
token = _immediate_txn.set(True) if commit else None
try: try:
yield session yield session
if commit: if commit:
@ -194,6 +213,8 @@ def get_db_session(commit: bool = False) -> Generator[Session, Any, None]:
session.rollback() session.rollback()
raise raise
finally: finally:
if token is not None:
_immediate_txn.reset(token)
# Rollback only if a transaction hasn't been committed. # Rollback only if a transaction hasn't been committed.
# In tests, the test_session fixture manages the session lifecycle, # In tests, the test_session fixture manages the session lifecycle,
# so we should not rollback here - that would undo other test operations. # so we should not rollback here - that would undo other test operations.
@ -201,28 +222,6 @@ def get_db_session(commit: bool = False) -> Generator[Session, Any, None]:
session.rollback() session.rollback()
@contextmanager
def get_immediate_db_session() -> Generator[Session, Any, None]:
"""A committing session that takes the SQLite write lock up front (`BEGIN IMMEDIATE`).
Use this for read-then-write transactions (read some rows, then UPDATE/INSERT them). A plain
`get_db_session(commit=True)` begins *deferred* and only upgrades to a writer at its first
write; if another connection holds the write lock at that moment, SQLite returns "database is
locked" *immediately* — `busy_timeout` is skipped for lock upgrades to avoid deadlock. Taking
the write lock at BEGIN instead lets `busy_timeout` (30s) absorb the contention.
The `_immediate_txn` flag is read by the engine's `begin` listener when the transaction opens
(on the caller's first statement), so keep queries inside this `with` block.
"""
previous = getattr(_immediate_txn, 'active', False)
_immediate_txn.active = True
try:
with get_db_session(commit=True) as session:
yield session
finally:
_immediate_txn.active = previous
@contextmanager @contextmanager
def get_db_curs(commit: bool = False) -> Generator[sqlite3.Cursor, Any, None]: def get_db_curs(commit: bool = False) -> Generator[sqlite3.Cursor, Any, None]:
""" """

View file

@ -35,7 +35,7 @@ from wrolpi.common import Base, ModelHelper, logger, wrol_mode_check, zig_zag, C
wrol_mode_enabled, background_task, get_absolute_media_path, timer, aiohttp_get, \ wrol_mode_enabled, background_task, get_absolute_media_path, timer, aiohttp_get, \
get_download_info, trim_file_name, get_wrolpi_config, TRACE_LEVEL, normalize_domain get_download_info, trim_file_name, get_wrolpi_config, TRACE_LEVEL, normalize_domain
from wrolpi.dates import TZDateTime, now, Seconds from wrolpi.dates import TZDateTime, now, Seconds
from wrolpi.db import get_db_session, get_db_curs, get_immediate_db_session from wrolpi.db import get_db_session, get_db_curs
from wrolpi.errors import InvalidDownload, UnrecoverableDownloadError, BotBlockedDownloadError, UnknownDownload, \ from wrolpi.errors import InvalidDownload, UnrecoverableDownloadError, BotBlockedDownloadError, UnknownDownload, \
ValidationError, DownloadError ValidationError, DownloadError
from wrolpi.events import Events from wrolpi.events import Events
@ -1110,9 +1110,9 @@ class DownloadManager:
so a transient "database is locked" can be handled there without retrying the signal so a transient "database is locked" can be handled there without retrying the signal
dispatches inside this transaction. dispatches inside this transaction.
Uses `get_immediate_db_session` because this is a read-then-write transaction (it reads the This is a read-then-write transaction (it reads the `new` downloads, then claims each by
`new` downloads, then claims each by setting `last_download_attempt`); a deferred lock setting `last_download_attempt`), so it must hold the write lock from BEGIN -- which
upgrade here fails instantly with "database is locked" under concurrency.""" `get_db_session(commit=True)` does."""
# Downloads claimed this cycle: dicts of (download_id, download_url, domain). Two things are # Downloads claimed this cycle: dicts of (download_id, download_url, domain). Two things are
# deferred until AFTER the immediate (write) transaction below commits: # deferred until AFTER the immediate (write) transaction below commits:
# * `_add_processing_domain` — it mutates shared, non-transactional state, so adding it # * `_add_processing_domain` — it mutates shared, non-transactional state, so adding it
@ -1126,7 +1126,7 @@ class DownloadManager:
# still holds even though `processing_domains` is not mutated until after commit. # still holds even though `processing_domains` is not mutated until after commit.
to_dispatch = [] to_dispatch = []
claimed = [] claimed = []
with get_immediate_db_session() as session: with get_db_session(commit=True) as session:
new_downloads = list(session.query(Download).filter( new_downloads = list(session.query(Download).filter(
Download.status == 'new', Download.status == 'new',
Download.domain not in self.processing_domains, Download.domain not in self.processing_domains,
@ -1348,7 +1348,10 @@ class DownloadManager:
"""Mark any recurring downloads that are due for download as "new". Start a download.""" """Mark any recurring downloads that are due for download as "new". Start a download."""
now_ = now() now_ = now()
with get_db_session() as session: # `commit=True` (a writer from BEGIN): this reads the recurring Downloads and then renews
# the due ones. As a read session its commit failed with "database is locked" whenever
# anything else wrote while it was reading -- a config import or refresh, constantly.
with get_db_session(commit=True) as session:
recurring = self.get_recurring_downloads(session) recurring = self.get_recurring_downloads(session)
renewed_count = 0 renewed_count = 0
for download in recurring: for download in recurring:

View file

@ -1,12 +1,9 @@
"""Tests for the FileWorker move task.""" """Tests for the FileWorker move task."""
import contextlib import contextlib
import sqlite3
import threading
import time
import pytest import pytest
from wrolpi.conftest import production_like_sessions from wrolpi.conftest import production_like_sessions, write_lock_held_briefly
from wrolpi.files import worker as worker_module from wrolpi.files import worker as worker_module
from wrolpi.files.lib import _move_file_group_files from wrolpi.files.lib import _move_file_group_files
from wrolpi.files.models import FileGroup, Directory from wrolpi.files.models import FileGroup, Directory
@ -539,32 +536,6 @@ async def test_file_worker_move_cleans_directory_records(
assert source_dir.exists(), "source should still exist on disk (empty)" assert source_dir.exists(), "source should still exist on disk (empty)"
@contextlib.contextmanager
def _write_lock_held_briefly(db_file: str, seconds: float = 1.0):
"""Hold the SQLite write lock on `db_file` for `seconds`, from another connection.
Simulates the concurrent writer (refresh, download, config save) that a real WROLPi runs
alongside a move. The lock is released while the test's move is still planning, so a
correctly-written move waits (busy_timeout) and then succeeds.
"""
holder = sqlite3.connect(db_file, timeout=30, check_same_thread=False)
holder.isolation_level = None
holder.execute('PRAGMA busy_timeout=30000')
holder.execute('BEGIN IMMEDIATE')
def release():
time.sleep(seconds)
holder.execute('ROLLBACK')
holder.close()
thread = threading.Thread(target=release, daemon=True)
thread.start()
try:
yield
finally:
thread.join(timeout=30)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_build_move_plan_waits_for_concurrent_writer( async def test_build_move_plan_waits_for_concurrent_writer(
async_client, test_session, test_directory, make_files_structure async_client, test_session, test_directory, make_files_structure
@ -585,7 +556,7 @@ async def test_build_move_plan_waits_for_concurrent_writer(
db_file = test_session.get_bind().url.database db_file = test_session.get_bind().url.database
with production_like_sessions(test_session): with production_like_sessions(test_session):
with _write_lock_held_briefly(db_file): with write_lock_held_briefly(db_file):
plan, _ = await build_move_plan_bulk([file1], dest) plan, _ = await build_move_plan_bulk([file1], dest)
assert plan == {file1: dest / 'file1.txt'} assert plan == {file1: dest / 'file1.txt'}
@ -622,7 +593,7 @@ async def test_file_worker_move_survives_concurrent_writer(
async def build_plan_then_hold_write_lock(*args, **kwargs): async def build_plan_then_hold_write_lock(*args, **kwargs):
plan = await real_build_move_plan_bulk(*args, **kwargs) plan = await real_build_move_plan_bulk(*args, **kwargs)
# Planning is done; the contention now falls entirely on the execute phase. # Planning is done; the contention now falls entirely on the execute phase.
stack.enter_context(_write_lock_held_briefly(db_file)) stack.enter_context(write_lock_held_briefly(db_file))
return plan return plan
monkeypatch.setattr(worker_module, 'build_move_plan_bulk', build_plan_then_hold_write_lock) monkeypatch.setattr(worker_module, 'build_move_plan_bulk', build_plan_then_hold_write_lock)

View file

@ -27,7 +27,7 @@ from wrolpi import flags
from wrolpi.common import apply_modelers, apply_refresh_cleanup from wrolpi.common import apply_modelers, apply_refresh_cleanup
from wrolpi.common import get_media_directory, get_wrolpi_config, logger, walk, chunks, unique_by_predicate from wrolpi.common import get_media_directory, get_wrolpi_config, logger, walk, chunks, unique_by_predicate
from wrolpi.dates import now from wrolpi.dates import now
from wrolpi.db import get_db_session, get_db_curs, get_immediate_db_session from wrolpi.db import get_db_session, get_db_curs
from wrolpi.errors import NoPrimaryFile from wrolpi.errors import NoPrimaryFile
from wrolpi.events import Events from wrolpi.events import Events
from wrolpi.files.lib import ( from wrolpi.files.lib import (
@ -600,7 +600,7 @@ async def build_move_plan_bulk(
# must begin as a writer. A deferred transaction upgrading to a writer mid-way gets "database # must begin as a writer. A deferred transaction upgrading to a writer mid-way gets "database
# is locked" *immediately* (busy_timeout is skipped for lock upgrades), so any concurrent # is locked" *immediately* (busy_timeout is skipped for lock upgrades), so any concurrent
# writer - a refresh, a download, a config save - would abort the whole move. # writer - a refresh, a download, a config save - would abort the whole move.
with get_immediate_db_session() as session: with get_db_session(commit=True) as session:
try: try:
# Create temp table for source paths. SQLite has no ON COMMIT DROP; the table is # Create temp table for source paths. SQLite has no ON COMMIT DROP; the table is
# explicitly dropped below on success/failure (and IF NOT EXISTS + DELETE guard # explicitly dropped below on success/failure (and IF NOT EXISTS + DELETE guard
@ -2045,7 +2045,7 @@ class FileWorker:
# it must begin as a writer; a deferred transaction's lock upgrade fails instantly when # it must begin as a writer; a deferred transaction's lock upgrade fails instantly when
# anything else is writing (see `build_move_plan_bulk`). # anything else is writing (see `build_move_plan_bulk`).
with flags.file_worker_discovery: with flags.file_worker_discovery:
with get_immediate_db_session() as session: with get_db_session(commit=True) as session:
await self._execute_move_chunks( await self._execute_move_chunks(
plan, session, created_directories, revert_plan plan, session, created_directories, revert_plan
) )

View file

@ -29,6 +29,7 @@ from wrolpi.common import logger, get_wrolpi_config, wrol_mode_enabled, get_medi
set_global_log_level, get_relative_to_media_directory, search_other_estimates, set_system_timezone set_global_log_level, get_relative_to_media_directory, search_other_estimates, set_system_timezone
from wrolpi.config_api import config_bp from wrolpi.config_api import config_bp
from wrolpi.dates import now from wrolpi.dates import now
from wrolpi.db import get_db_session
from wrolpi.downloader import download_manager from wrolpi.downloader import download_manager
from wrolpi.errors import WROLModeEnabled, InvalidConfig, ValidationError from wrolpi.errors import WROLModeEnabled, InvalidConfig, ValidationError
from wrolpi.events import get_events, Events from wrolpi.events import get_events, Events
@ -355,16 +356,21 @@ async def post_download(request: Request, body: schema.DownloadRequest):
# Raises an InvalidDownload if the Downloader cannot be found. # Raises an InvalidDownload if the Downloader cannot be found.
download_manager.find_downloader_by_name(body.downloader) download_manager.find_downloader_by_name(body.downloader)
session = request.ctx.session
kwargs = dict(downloader_name=body.downloader, kwargs = dict(downloader_name=body.downloader,
sub_downloader_name=body.sub_downloader, reset_attempts=True, sub_downloader_name=body.sub_downloader, reset_attempts=True,
destination=body.destination, tag_names=body.tag_names, destination=body.destination, tag_names=body.tag_names,
settings=body.settings, override_skip=True) settings=body.settings, override_skip=True)
if body.frequency: # Not `request.ctx.session`: creating a Download reads (the Tags, then any existing Download for
download_manager.recurring_download(session, body.urls[0], body.frequency, collection_id=body.collection_id, # each URL) before it INSERTs, and a deferred transaction fails that lock upgrade *instantly*
**kwargs) # with "database is locked" if anything else wrote in between -- which is what a user creating a
else: # download during a refresh hit. This session also commits before the handler returns, so the
download_manager.create_downloads(session, body.urls, **kwargs) # background dispatch `create_downloads` fires does not wait on a lock this request still holds.
with get_db_session(commit=True) as session:
if body.frequency:
download_manager.recurring_download(session, body.urls[0], body.frequency,
collection_id=body.collection_id, **kwargs)
else:
download_manager.create_downloads(session, body.urls, **kwargs)
if download_manager.disabled.is_set() or download_manager.stopped.is_set(): if download_manager.disabled.is_set() or download_manager.stopped.is_set():
# Downloads are disabled, warn the user. # Downloads are disabled, warn the user.
Events.send_downloads_disabled('Download created. But, downloads are disabled.') Events.send_downloads_disabled('Download created. But, downloads are disabled.')

View file

@ -1,7 +1,7 @@
import sqlite3 import sqlite3
from wrolpi.conftest import production_like_sessions from wrolpi.conftest import production_like_sessions
from wrolpi.db import get_db_session, get_immediate_db_session, _configure_sqlite_connection from wrolpi.db import get_db_session, _configure_sqlite_connection
class _FakeCursor: class _FakeCursor:
@ -120,39 +120,25 @@ def _probe_write_lock_is_held(db_file: str) -> bool:
probe.close() probe.close()
def test_immediate_session_takes_write_lock_up_front(test_session): def test_write_session_takes_write_lock_up_front(test_session):
"""`get_immediate_db_session` must acquire the SQLite write lock at BEGIN. """`get_db_session(commit=True)` must acquire the SQLite write lock at BEGIN.
Regression test for `database is locked` on `UPDATE download SET last_download_attempt`: Regression test for `database is locked` on `UPDATE download SET last_download_attempt`:
a deferred (read-then-write) transaction that tries to upgrade its lock while another a deferred (read-then-write) transaction that tries to upgrade its lock while another
connection holds the write lock gets SQLITE_BUSY *immediately* — busy_timeout is ignored connection holds the write lock gets SQLITE_BUSY *immediately* — busy_timeout is ignored
for lock upgrades to avoid deadlock. Taking the write lock up front (BEGIN IMMEDIATE) for lock upgrades to avoid deadlock. Taking the write lock up front (BEGIN IMMEDIATE)
lets busy_timeout absorb the contention instead of erroring instantly. lets busy_timeout absorb the contention instead of erroring instantly. `commit=True` is
""" the declaration of write intent, so it is what arms this — no opt-in helper to forget.
db_file = test_session.get_bind().url.database
with production_like_sessions(test_session):
with get_immediate_db_session() as session:
# Trigger the transaction's BEGIN with a trivial read (as the download dispatcher
# does before writing). This session already holds the write lock.
session.execute('SELECT 1')
assert _probe_write_lock_is_held(db_file), \
'immediate session did not hold the write lock after BEGIN'
def test_plain_write_session_stays_deferred(test_session):
"""A plain `get_db_session(commit=True)` stays deferred (no write lock until it actually writes).
Only `get_immediate_db_session` opts into the up-front write lock, so ordinary write sessions
keep WAL read-concurrency and don't serialize behind each other at BEGIN.
""" """
db_file = test_session.get_bind().url.database db_file = test_session.get_bind().url.database
with production_like_sessions(test_session): with production_like_sessions(test_session):
with get_db_session(commit=True) as session: with get_db_session(commit=True) as session:
# Trigger the transaction's BEGIN with a trivial read (as the download dispatcher
# does before writing). This session already holds the write lock.
session.execute('SELECT 1') session.execute('SELECT 1')
assert not _probe_write_lock_is_held(db_file), \ assert _probe_write_lock_is_held(db_file), \
'plain write session unexpectedly holds the write lock before writing' 'immediate session did not hold the write lock after BEGIN'
def test_read_session_does_not_take_write_lock(test_session): def test_read_session_does_not_take_write_lock(test_session):

View file

@ -1713,7 +1713,7 @@ async def test_download_dispatched_outside_immediate_transaction(test_session, t
"""The download signal must be dispatched AFTER the claiming write transaction commits. """The download signal must be dispatched AFTER the claiming write transaction commits.
Regression test for a production deadlock (found on 10.0.0.9, where downloads silently stopped): Regression test for a production deadlock (found on 10.0.0.9, where downloads silently stopped):
`_dispatch_new_downloads` claimed downloads inside a `get_immediate_db_session()` (BEGIN `_dispatch_new_downloads` claimed downloads inside a write (BEGIN
IMMEDIATE) transaction and `await`ed `api_app.dispatch(...)` while that transaction was still IMMEDIATE) transaction and `await`ed `api_app.dispatch(...)` while that transaction was still
open. Under NullPool (production) the dispatched `signal_download_download` opens its OWN open. Under NullPool (production) the dispatched `signal_download_download` opens its OWN
connection and — inheriting the `_immediate_txn` flag — also issues BEGIN IMMEDIATE, which connection and — inheriting the `_immediate_txn` flag — also issues BEGIN IMMEDIATE, which

View file

@ -10,6 +10,7 @@ from wrolpi.api_utils import json_error_handler
from wrolpi.common import get_wrolpi_config from wrolpi.common import get_wrolpi_config
from wrolpi.downloader import Download, get_download_manager_config from wrolpi.downloader import Download, get_download_manager_config
from wrolpi.errors import ValidationError, SearchEmpty from wrolpi.errors import ValidationError, SearchEmpty
from wrolpi.conftest import production_like_sessions, write_lock_held_briefly
from wrolpi.test.common import assert_dict_contains from wrolpi.test.common import assert_dict_contains
@ -969,3 +970,34 @@ async def test_search_other_estimates(async_client, test_session, channel_factor
request, response = await async_client.post('/api/search_other_estimates', json=body) request, response = await async_client.post('/api/search_other_estimates', json=body)
assert response.status_code == HTTPStatus.OK assert response.status_code == HTTPStatus.OK
assert response.json['others']['channel_count'] == 1, 'Only one Channel is tagged.' assert response.json['others']['channel_count'] == 1, 'Only one Channel is tagged.'
@pytest.mark.asyncio
async def test_mutating_request_survives_concurrent_writer(async_client, test_session, test_downloader,
test_download_manager):
"""Creating a Download must not fail because something else is writing.
Regression test for the production failure: a user created a video download while a refresh was
running and got `database is locked` on `INSERT INTO download`. The request session begins
*deferred*, the handler reads (tag/duplicate checks) before it INSERTs, and SQLite refuses that
mid-transaction lock upgrade instantly -- busy_timeout is skipped for upgrades. Mutating
requests must begin as writers so busy_timeout absorbs the contention instead.
"""
db_file = test_session.get_bind().url.database
body = json.dumps(dict(urls=['https://example.com/during-refresh'], downloader=test_downloader.name))
# Release anything the fixtures left open on the shared session's connection; only the lock the
# test takes below should contend with the request.
test_session.commit()
with production_like_sessions(test_session) as maker:
with write_lock_held_briefly(db_file):
request, response = await async_client.post('/api/download', content=body)
assert response.status_code == HTTPStatus.CREATED, response.body
# A separate session sees the committed Download, as the download worker would.
session = maker()
try:
assert [i.url for i in session.query(Download).all()] == ['https://example.com/during-refresh']
finally:
session.close()