Skip to content

Commit 04c7a92

Browse files
Merge pull request #915 from BrachiumX/main
Magic shelf kobo sync fix
2 parents eeb936e + 35ad418 commit 04c7a92

3 files changed

Lines changed: 201 additions & 114 deletions

File tree

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):

0 commit comments

Comments
 (0)