88import os
99import re
1010import json
11+ import time
12+ import threading
1113from datetime import datetime , timezone
1214from urllib .parse import quote
1315import 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
12271267def lcase (s ):
0 commit comments