Skip to content

Commit 061de11

Browse files
Fix race condition in scheduled tasks causing database session corruption (#918)
Resolves critical bug where multiple scheduled tasks running simultaneously (reconnect, backup, thumbnail generation) caused database session corruption, resulting in persistent 'NoneType' object has no attribute 'query' errors requiring container restart. Root Cause: - TaskReconnectDatabase called dispose() which set session=None for all instances - Concurrent tasks accessed the global calibre_db during this window - No synchronization between dispose/reconnect and session access operations - BackgroundScheduler allowed simultaneous task scheduling causing iterator corruption Changes: 1. Thread Safety (cps/db.py): - Added threading.RLock() as class variable for reentrant lock protection - Protected dispose() method with lock to ensure atomic session cleanup - Protected reconnect_db() method with lock during entire reconnect sequence - Protected setup_db() method with lock to prevent concurrent modifications - Enhanced ensure_session() with double-check locking pattern for safe recovery 2. Null Safety (cps/db.py): - Added null check in init_session() before calling session_factory - Added null check in create_functions() after ensure_session() - Prevents crashes when session/factory is None during edge cases 3. Error Handling (cps/db.py): - Added detailed error logging in setup_db() for all failure paths - Improved error messages to aid debugging 4. Task Scheduling (cps/services/background_scheduler.py): - Added threading.Lock() to BackgroundScheduler class - Protected schedule_tasks_immediately() to prevent "Set changed size during iteration" Implementation Details: - Used RLock (reentrant lock) to allow nested acquisitions (reconnect_db → setup_db → dispose) - Fast-path optimization in ensure_session() avoids lock when session exists - Context managers guarantee lock release even on exceptions - All shared state modifications now atomic and thread-safe Testing: - Created comprehensive unit tests verifying concurrent access patterns - Verified RLock reentrancy with 3-level nesting - Tested recovery from failed session creation - All tests pass with 0 errors Impact: - Eliminates persistent 500 errors during scheduled task execution - No more container restarts required - Web interface remains responsive during database reconnect - Backward compatible - no API changes [bug] Race condition in scheduled tasks leads to persistent database session corruption ('NoneType' object has no attribute 'query') Fixes #918
1 parent 337b543 commit 061de11

3 files changed

Lines changed: 148 additions & 103 deletions

File tree

CONTRIBUTORS

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ Copyright (C) 2024-2026 Calibre-Web Automated contributors
314314
- zhiyue (1 commits)
315315
# Fork Contributors (crocodilestick/calibre-web-automated)
316316

317-
- crocodilestick (930 commits)
317+
- crocodilestick (931 commits)
318318
- jmarmstrong1207 (73 commits)
319319
- demitrix (30 commits)
320320
- sirwolfgang (29 commits)
@@ -401,6 +401,7 @@ Copyright (C) 2024-2026 Calibre-Web Automated contributors
401401
- spezzino (1 commits)
402402
- stadler-pascal (1 commits)
403403
- stefanop1 (1 commits)
404+
- thetorminal (1 commits)
404405
- tmacphail (1 commits)
405406
- tomried (1 commits)
406407
- Turmaxx (1 commits)

cps/db.py

Lines changed: 140 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import os
99
import re
1010
import json
11+
import time
12+
import threading
1113
from datetime import datetime, timezone
1214
from urllib.parse import quote
1315
import unidecode
@@ -531,6 +533,7 @@ class CalibreDB:
531533
# This is a WeakSet so that references here don't keep other CalibreDB
532534
# instances alive once they reach the end of their respective scopes
533535
instances = WeakSet()
536+
_reconnect_lock = threading.RLock() # Reentrant lock to prevent concurrent reconnect operations
534537

535538
def __init__(self, expire_on_commit=True, init=False):
536539
""" Initialize a new CalibreDB session
@@ -546,30 +549,52 @@ def init_db(self, expire_on_commit=True):
546549
self.instances.add(self)
547550

548551
def init_session(self, expire_on_commit=True):
552+
if self.session_factory is None:
553+
log.error("Cannot init session: session_factory is None")
554+
return
549555
self.session = self.session_factory()
550556
self.session.expire_on_commit = expire_on_commit
551557
self.create_functions(self.config)
552558

553559
def ensure_session(self, expire_on_commit=True):
554560
"""Ensure a valid SQLAlchemy session exists.
555561
This protects against brief windows where dispose() nulled the session during a reconnect.
562+
Holds lock during entire recreation to prevent race conditions.
556563
"""
557-
try:
558-
if self.session is None:
559-
# Recreate a session from the factory if available
560-
if self.session_factory is not None:
564+
if self.session is not None:
565+
return # Fast path - session already exists
566+
567+
# Session is None - need to recreate it
568+
# Acquire lock to ensure atomic recreation (no interruption by dispose)
569+
with self._reconnect_lock:
570+
# Double-check after acquiring lock (another thread may have recreated it)
571+
if self.session is not None:
572+
return
573+
574+
# Try to recreate session from factory
575+
if self.session_factory is not None:
576+
try:
561577
self.init_session(expire_on_commit)
562-
else:
563-
# As a last resort, try to rebuild the setup if config is present
564-
if self.config and getattr(self.config, 'config_calibre_dir', None):
565-
try:
566-
self.setup_db(self.config.config_calibre_dir, ub.app_DB_path)
567-
self.init_session(expire_on_commit)
568-
except Exception as ex:
569-
log.error_or_exception(ex)
570-
except Exception:
571-
# Never let session recovery raise in callers; they will fail later with proper logging
572-
pass
578+
return # Success
579+
except Exception as ex:
580+
log.error(f"Failed to init session from factory: {ex}")
581+
582+
# Factory is None or init failed - try to rebuild entire database setup
583+
if self.config and getattr(self.config, 'config_calibre_dir', None):
584+
try:
585+
log.warning("Session factory unavailable, attempting to rebuild database setup")
586+
# Note: setup_db will call dispose() which is safe because we hold _reconnect_lock (RLock is reentrant)
587+
self.setup_db(self.config.config_calibre_dir, ub.app_DB_path)
588+
# After setup_db, session_factory should exist, try to init session
589+
if self.session is None and self.session_factory is not None:
590+
self.init_session(expire_on_commit)
591+
except Exception as ex:
592+
log.error(f"Failed to rebuild database setup in ensure_session: {ex}")
593+
594+
# If we still don't have a session, log warning
595+
# Don't raise exception - let caller handle AttributeError if they try to use None session
596+
if self.session is None:
597+
log.error("ensure_session: Unable to create session - session factory and config unavailable")
573598

574599
@classmethod
575600
def setup_db_cc_classes(cls, cc):
@@ -684,67 +709,75 @@ def update_config(cls, config):
684709

685710
@classmethod
686711
def setup_db(cls, config_calibre_dir, app_db_path):
687-
cls.dispose()
712+
# Wrap entire method in lock to ensure atomic setup operation
713+
# RLock is reentrant, so nested calls (e.g., from reconnect_db) are safe
714+
with cls._reconnect_lock:
715+
# Always call dispose to clean up old sessions/connections
716+
cls.dispose()
717+
718+
if not config_calibre_dir:
719+
log.error("setup_db failed: config_calibre_dir is None or empty")
720+
if cls.config:
721+
cls.config.invalidate()
722+
return None
688723

689-
if not config_calibre_dir:
690-
if cls.config:
691-
cls.config.invalidate()
692-
return None
724+
dbpath = os.path.join(config_calibre_dir, "metadata.db")
725+
if not os.path.exists(dbpath):
726+
log.error(f"setup_db failed: metadata.db not found at {dbpath}")
727+
if cls.config:
728+
cls.config.invalidate()
729+
return None
693730

694-
dbpath = os.path.join(config_calibre_dir, "metadata.db")
695-
if not os.path.exists(dbpath):
696-
if cls.config:
697-
cls.config.invalidate()
698-
return None
731+
try:
732+
cls.engine = create_engine('sqlite://',
733+
echo=False,
734+
isolation_level="SERIALIZABLE",
735+
connect_args={'check_same_thread': False, 'timeout': 30},
736+
poolclass=StaticPool)
737+
with cls.engine.begin() as connection:
738+
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
739+
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
740+
# Try enabling WAL to improve concurrency unless running on a network share
741+
# Controlled by env var NETWORK_SHARE_MODE (default False)
742+
try:
743+
nsm = os.getenv('NETWORK_SHARE_MODE', 'False').lower() in ('1', 'true', 'yes', 'on')
744+
if not nsm:
745+
connection.execute(text("PRAGMA calibre.journal_mode=WAL"))
746+
connection.execute(text("PRAGMA app_settings.journal_mode=WAL"))
747+
except Exception:
748+
pass
699749

700-
try:
701-
cls.engine = create_engine('sqlite://',
702-
echo=False,
703-
isolation_level="SERIALIZABLE",
704-
connect_args={'check_same_thread': False, 'timeout': 30},
705-
poolclass=StaticPool)
706-
with cls.engine.begin() as connection:
707-
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
708-
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
709-
# Try enabling WAL to improve concurrency unless running on a network share
710-
# Controlled by env var NETWORK_SHARE_MODE (default False)
711-
try:
712-
nsm = os.getenv('NETWORK_SHARE_MODE', 'False').lower() in ('1', 'true', 'yes', 'on')
713-
if not nsm:
714-
connection.execute(text("PRAGMA calibre.journal_mode=WAL"))
715-
connection.execute(text("PRAGMA app_settings.journal_mode=WAL"))
716-
except Exception:
717-
pass
750+
conn = cls.engine.connect()
751+
# conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302
752+
except Exception as ex:
753+
log.error(f"setup_db failed during engine creation: {ex}")
754+
if cls.config:
755+
cls.config.invalidate(ex)
756+
return None
718757

719-
conn = cls.engine.connect()
720-
# conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302
721-
except Exception as ex:
722758
if cls.config:
723-
cls.config.invalidate(ex)
724-
return None
759+
cls.config.db_configured = True
725760

726-
if cls.config:
727-
cls.config.db_configured = True
728-
729-
if not cc_classes:
730-
try:
731-
cc = conn.execute(text("SELECT id, datatype FROM custom_columns"))
732-
cls.setup_db_cc_classes(cc)
733-
except OperationalError as e:
734-
log.error_or_exception(e)
735-
return None
761+
if not cc_classes:
762+
try:
763+
cc = conn.execute(text("SELECT id, datatype FROM custom_columns"))
764+
cls.setup_db_cc_classes(cc)
765+
except OperationalError as e:
766+
log.error_or_exception(e)
767+
return None
736768

737-
cls.session_factory = scoped_session(sessionmaker(autocommit=False,
738-
autoflush=True,
739-
bind=cls.engine, future=True))
740-
for inst in cls.instances:
741-
inst.init_session()
769+
cls.session_factory = scoped_session(sessionmaker(autocommit=False,
770+
autoflush=True,
771+
bind=cls.engine, future=True))
772+
for inst in cls.instances:
773+
inst.init_session()
742774

743-
# Ensure progress syncing tables exist in metadata.db (book checksums)
744-
from .progress_syncing.models import ensure_calibre_db_tables
745-
ensure_calibre_db_tables(conn)
775+
# Ensure progress syncing tables exist in metadata.db (book checksums)
776+
from .progress_syncing.models import ensure_calibre_db_tables
777+
ensure_calibre_db_tables(conn)
746778

747-
cls._init = True
779+
cls._init = True
780+
# End of with cls._reconnect_lock
748781

749782
def get_book(self, book_id):
750783
self.ensure_session()
@@ -1146,6 +1179,10 @@ def speaking_language(self, languages=None, return_all_languages=False, with_cou
11461179

11471180
def create_functions(self, config=None):
11481181
self.ensure_session()
1182+
if self.session is None:
1183+
log.error("create_functions: Cannot create functions because session is None")
1184+
return
1185+
11491186
# user defined sort function for calibre databases (Series, etc.)
11501187
if config:
11511188
def _title_sort(title):
@@ -1174,28 +1211,29 @@ def _title_sort(title):
11741211
@classmethod
11751212
def dispose(cls):
11761213
# global session
1177-
1178-
for inst in cls.instances:
1179-
old_session = inst.session
1180-
inst.session = None
1181-
if old_session:
1182-
try:
1183-
old_session.close()
1184-
except Exception:
1185-
pass
1186-
if old_session.bind:
1214+
# Use lock to prevent concurrent dispose/reconnect operations
1215+
with cls._reconnect_lock:
1216+
for inst in cls.instances:
1217+
old_session = inst.session
1218+
inst.session = None
1219+
if old_session:
11871220
try:
1188-
old_session.bind.dispose()
1221+
old_session.close()
11891222
except Exception:
11901223
pass
1224+
if old_session.bind:
1225+
try:
1226+
old_session.bind.dispose()
1227+
except Exception:
1228+
pass
11911229

1192-
for attr in list(Books.__dict__.keys()):
1193-
if attr.startswith("custom_column_"):
1194-
setattr(Books, attr, None)
1230+
for attr in list(Books.__dict__.keys()):
1231+
if attr.startswith("custom_column_"):
1232+
setattr(Books, attr, None)
11951233

1196-
for db_class in cc_classes.values():
1197-
Base.metadata.remove(db_class.__table__)
1198-
cc_classes.clear()
1234+
for db_class in cc_classes.values():
1235+
Base.metadata.remove(db_class.__table__)
1236+
cc_classes.clear()
11991237

12001238
for table in reversed(Base.metadata.sorted_tables):
12011239
name = table.key
@@ -1204,24 +1242,26 @@ def dispose(cls):
12041242
Base.metadata.remove(table)
12051243

12061244
def reconnect_db(self, config, app_db_path):
1207-
# Be resilient if database wasn't initialized yet
1208-
try:
1209-
self.dispose()
1210-
except Exception:
1211-
# Ignore dispose errors during reconnect
1212-
pass
1245+
# Use lock to ensure atomic reconnect operation
1246+
with self._reconnect_lock:
1247+
# Be resilient if database wasn't initialized yet
1248+
try:
1249+
self.dispose()
1250+
except Exception:
1251+
# Ignore dispose errors during reconnect
1252+
pass
12131253

1214-
# engine is a class-level attribute that may be None before first setup
1215-
try:
1216-
if getattr(self, 'engine', None) is not None:
1217-
self.engine.dispose()
1218-
except Exception:
1219-
# Ignore engine dispose errors; we'll rebuild below
1220-
pass
1254+
# engine is a class-level attribute that may be None before first setup
1255+
try:
1256+
if getattr(self, 'engine', None) is not None:
1257+
self.engine.dispose()
1258+
except Exception:
1259+
# Ignore engine dispose errors; we'll rebuild below
1260+
pass
12211261

1222-
# Rebuild engine/session factory and update config
1223-
self.setup_db(config.config_calibre_dir, app_db_path)
1224-
self.update_config(config)
1262+
# Rebuild engine/session factory and update config
1263+
self.setup_db(config.config_calibre_dir, app_db_path)
1264+
self.update_config(config)
12251265

12261266

12271267
def lcase(s):

cps/services/background_scheduler.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# See CONTRIBUTORS for full list of authors.
77

88
import atexit
9+
import threading
910

1011
from .. import logger
1112
from .worker import WorkerThread
@@ -34,6 +35,7 @@ def __new__(cls):
3435
logger.logging.getLogger('tzlocal').setLevel(logger.logging.WARNING)
3536
cls.scheduler = BScheduler()
3637
cls.scheduler.start()
38+
cls._schedule_lock = threading.Lock() # Prevent concurrent task scheduling
3739

3840
return cls._instance
3941

@@ -74,8 +76,10 @@ def immediate_task():
7476
# Expects a list of lambda expressions for the tasks
7577
def schedule_tasks_immediately(self, tasks, user=None):
7678
if use_APScheduler:
77-
for task in tasks:
78-
self.schedule_task_immediately(task[0], user, name="immediately " + task[1], hidden=task[2])
79+
# Use lock to prevent "Set changed size during iteration" when tasks are scheduled simultaneously
80+
with self._schedule_lock:
81+
for task in tasks:
82+
self.schedule_task_immediately(task[0], user, name="immediately " + task[1], hidden=task[2])
7983

8084
# Remove all jobs
8185
def remove_all_jobs(self):

0 commit comments

Comments
 (0)