@@ -23,7 +23,7 @@ def __init__(self, **kwargs):
2323 from sqlalchemy import create_engine , inspect , event
2424 from sqlalchemy import Index
2525 from sqlalchemy .engine import Engine
26- from sqlalchemy .orm import sessionmaker
26+ from sqlalchemy .orm import sessionmaker , scoped_session
2727
2828 self .database_uri = kwargs .get ('database_uri' , False )
2929
@@ -35,7 +35,10 @@ def __init__(self, **kwargs):
3535 if not self .database_uri :
3636 self .database_uri = 'sqlite:///db.sqlite3'
3737
38- self .engine = create_engine (self .database_uri )
38+ # Configure connection pool with safe defaults to prevent exhaustion
39+ # Note: SQLite uses SingletonThreadPool which doesn't support these params
40+ # PostgreSQL, MySQL, etc. use QueuePool which does support them
41+ pool_config = {}
3942
4043 if self .database_uri .startswith ('sqlite://' ):
4144
@@ -66,6 +69,23 @@ def set_sqlite_pragma(dbapi_connection, connection_record):
6669 cursor .execute ('PRAGMA synchronous=NORMAL' )
6770 cursor .close ()
6871
72+ else :
73+ # Only apply pool configuration for databases that support QueuePool
74+ # pool_size: Maximum persistent connections (10)
75+ # max_overflow: Additional connections during peak load (20)
76+ # pool_timeout: Seconds to wait for connection before error (30)
77+ # pool_recycle: Recycle connections after 1 hour to prevent stale connections
78+ # pool_pre_ping: Test connections before using to detect disconnects
79+ pool_config = {
80+ 'pool_size' : kwargs .get ('pool_size' , 10 ),
81+ 'max_overflow' : kwargs .get ('max_overflow' , 20 ),
82+ 'pool_timeout' : kwargs .get ('pool_timeout' , 30 ),
83+ 'pool_recycle' : kwargs .get ('pool_recycle' , 3600 ),
84+ 'pool_pre_ping' : kwargs .get ('pool_pre_ping' , True ),
85+ }
86+
87+ self .engine = create_engine (self .database_uri , ** pool_config )
88+
6989 if not inspect (self .engine ).has_table ('statement' ):
7090 self .create_database ()
7191
@@ -91,7 +111,10 @@ def set_sqlite_pragma(dbapi_connection, connection_record):
91111
92112 search_in_response_to_index .create (bind = self .engine )
93113
94- self .Session = sessionmaker (bind = self .engine , expire_on_commit = True )
114+ # Use a scoped session for thread-safe session management
115+ # This provides thread-local session storage to prevent session sharing across threads
116+ session_factory = sessionmaker (bind = self .engine , expire_on_commit = True )
117+ self .Session = scoped_session (session_factory )
95118
96119 def get_statement_model (self ):
97120 """
@@ -119,9 +142,11 @@ def count(self) -> int:
119142 Statement = self .get_model ('statement' )
120143
121144 session = self .Session ()
122- statement_count = session .query (Statement ).count ()
123- session .close ()
124- return statement_count
145+ try :
146+ statement_count = session .query (Statement ).count ()
147+ return statement_count
148+ finally :
149+ session .close ()
125150
126151 def remove (self , statement_text ):
127152 """
@@ -131,13 +156,14 @@ def remove(self, statement_text):
131156 """
132157 Statement = self .get_model ('statement' )
133158 session = self .Session ()
159+ try :
160+ query = session .query (Statement ).filter_by (text = statement_text )
161+ record = query .first ()
134162
135- query = session .query (Statement ).filter_by (text = statement_text )
136- record = query .first ()
137-
138- session .delete (record )
139- session .commit ()
140- session .close ()
163+ session .delete (record )
164+ session .commit ()
165+ finally :
166+ session .close ()
141167
142168 def filter (self , ** kwargs ):
143169 """
@@ -152,8 +178,6 @@ def filter(self, **kwargs):
152178 Statement = self .get_model ('statement' )
153179 Tag = self .get_model ('tag' )
154180
155- session = self .Session ()
156-
157181 page_size = kwargs .pop ('page_size' , 1000 )
158182 order_by = kwargs .pop ('order_by' , None )
159183 tags = kwargs .pop ('tags' , [])
@@ -167,65 +191,69 @@ def filter(self, **kwargs):
167191 if isinstance (tags , str ):
168192 tags = [tags ]
169193
170- if len (kwargs ) == 0 :
171- statements = session .query (Statement ).filter ()
172- else :
173- statements = session .query (Statement ).filter_by (** kwargs )
174-
175- if tags :
176- statements = statements .join (Statement .tags ).filter (
177- Tag .name .in_ (tags )
178- )
179-
180- if exclude_text :
181- statements = statements .filter (
182- ~ Statement .text .in_ (exclude_text )
183- )
194+ # Use context manager to ensure session cleanup even if generator is partially consumed
195+ session = self .Session ()
196+ try :
197+ if len (kwargs ) == 0 :
198+ statements = session .query (Statement ).filter ()
199+ else :
200+ statements = session .query (Statement ).filter_by (** kwargs )
201+
202+ if tags :
203+ statements = statements .join (Statement .tags ).filter (
204+ Tag .name .in_ (tags )
205+ )
184206
185- if exclude_text_words :
186- or_word_query = [
187- Statement .text .ilike ('%' + word + '%' ) for word in exclude_text_words
188- ]
189- statements = statements .filter (
190- ~ or_ (* or_word_query )
191- )
207+ if exclude_text :
208+ statements = statements .filter (
209+ ~ Statement .text .in_ (exclude_text )
210+ )
192211
193- if persona_not_startswith :
194- statements = statements .filter (
195- ~ Statement .persona .startswith ('bot:' )
196- )
212+ if exclude_text_words :
213+ or_word_query = [
214+ Statement .text .ilike ('%' + word + '%' ) for word in exclude_text_words
215+ ]
216+ statements = statements .filter (
217+ ~ or_ (* or_word_query )
218+ )
197219
198- if search_text_contains :
199- or_query = [
200- Statement .search_text .contains (word ) for word in search_text_contains .split (' ' )
201- ]
202- statements = statements .filter (
203- or_ (* or_query )
204- )
220+ if persona_not_startswith :
221+ statements = statements .filter (
222+ ~ Statement .persona .startswith ('bot:' )
223+ )
205224
206- if search_in_response_to_contains :
207- or_query = [
208- Statement .search_in_response_to .contains (word ) for word in search_in_response_to_contains .split (' ' )
209- ]
210- statements = statements .filter (
211- or_ (* or_query )
212- )
225+ if search_text_contains :
226+ or_query = [
227+ Statement .search_text .contains (word ) for word in search_text_contains .split (' ' )
228+ ]
229+ statements = statements .filter (
230+ or_ (* or_query )
231+ )
213232
214- if order_by :
233+ if search_in_response_to_contains :
234+ or_query = [
235+ Statement .search_in_response_to .contains (word ) for word in search_in_response_to_contains .split (' ' )
236+ ]
237+ statements = statements .filter (
238+ or_ (* or_query )
239+ )
215240
216- if 'created_at' in order_by :
217- index = order_by .index ('created_at' )
218- order_by [index ] = Statement .created_at .asc ()
241+ if order_by :
219242
220- statements = statements .order_by (* order_by )
243+ if 'created_at' in order_by :
244+ index = order_by .index ('created_at' )
245+ order_by [index ] = Statement .created_at .asc ()
221246
222- total_statements = statements .count ( )
247+ statements = statements .order_by ( * order_by )
223248
224- for start_index in range (0 , total_statements , page_size ):
225- for statement in statements .slice (start_index , start_index + page_size ):
226- yield self .model_to_object (statement )
249+ total_statements = statements .count ()
227250
228- session .close ()
251+ for start_index in range (0 , total_statements , page_size ):
252+ for statement in statements .slice (start_index , start_index + page_size ):
253+ yield self .model_to_object (statement )
254+ finally :
255+ # Always close session, even if generator is abandoned or exception occurs
256+ session .close ()
229257
230258 def create (
231259 self ,
@@ -336,8 +364,11 @@ def create_many(self, statements):
336364 statement_model_object .tags .append (tag )
337365 create_statements .append (statement_model_object )
338366
339- session .add_all (create_statements )
340- session .commit ()
367+ try :
368+ session .add_all (create_statements )
369+ session .commit ()
370+ finally :
371+ session .close ()
341372
342373 def update (self , statement ):
343374 """
@@ -348,49 +379,51 @@ def update(self, statement):
348379 Tag = self .get_model ('tag' )
349380
350381 session = self .Session ()
351- record = None
382+ try :
383+ record = None
352384
353- if hasattr (statement , 'id' ) and statement .id is not None :
354- record = session .get (Statement , statement .id )
355- else :
356- record = session .query (Statement ).filter (
357- Statement .text == statement .text ,
358- Statement .conversation == statement .conversation ,
359- ).first ()
360-
361- # Create a new statement entry if one does not already exist
362- if not record :
363- record = Statement (
364- text = statement .text ,
365- conversation = statement .conversation ,
366- persona = statement .persona
367- )
385+ if hasattr (statement , 'id' ) and statement .id is not None :
386+ record = session .get (Statement , statement .id )
387+ else :
388+ record = session .query (Statement ).filter (
389+ Statement .text == statement .text ,
390+ Statement .conversation == statement .conversation ,
391+ ).first ()
368392
369- # Update the response value
370- record .in_response_to = statement .in_response_to
393+ # Create a new statement entry if one does not already exist
394+ if not record :
395+ record = Statement (
396+ text = statement .text ,
397+ conversation = statement .conversation ,
398+ persona = statement .persona
399+ )
371400
372- record .created_at = statement .created_at
401+ # Update the response value
402+ record .in_response_to = statement .in_response_to
373403
374- if not statement .search_text :
375- if self .raise_on_missing_search_text :
376- raise Exception ('update issued without search_text value' )
404+ record .created_at = statement .created_at
377405
378- if statement . in_response_to and not statement .search_in_response_to :
379- if self .raise_on_missing_search_text :
380- raise Exception ('update issued without search_in_response_to value' )
406+ if not statement .search_text :
407+ if self .raise_on_missing_search_text :
408+ raise Exception ('update issued without search_text value' )
381409
382- for tag_name in statement .get_tags ():
383- tag = session .query (Tag ).filter_by (name = tag_name ).first ()
410+ if statement .in_response_to and not statement .search_in_response_to :
411+ if self .raise_on_missing_search_text :
412+ raise Exception ('update issued without search_in_response_to value' )
384413
385- if not tag :
386- # Create the record
387- tag = Tag (name = tag_name )
414+ for tag_name in statement .get_tags ():
415+ tag = session .query (Tag ).filter_by (name = tag_name ).first ()
388416
389- record .tags .append (tag )
417+ if not tag :
418+ # Create the record
419+ tag = Tag (name = tag_name )
390420
391- session .add (record )
392- session .commit ()
393- session .close ()
421+ record .tags .append (tag )
422+
423+ session .add (record )
424+ session .commit ()
425+ finally :
426+ session .close ()
394427
395428 def get_random (self ):
396429 """
@@ -399,17 +432,19 @@ def get_random(self):
399432 Statement = self .get_model ('statement' )
400433
401434 session = self .Session ()
402- count = self .count ()
403- if count < 1 :
404- raise self .EmptyDatabaseException ()
435+ try :
436+ count = self .count ()
437+ if count < 1 :
438+ raise self .EmptyDatabaseException ()
405439
406- random_index = random .randrange (0 , count )
407- random_statement = session .query (Statement )[random_index ]
440+ random_index = random .randrange (0 , count )
441+ random_statement = session .query (Statement )[random_index ]
408442
409- statement = self .model_to_object (random_statement )
443+ statement = self .model_to_object (random_statement )
410444
411- session .close ()
412- return statement
445+ return statement
446+ finally :
447+ session .close ()
413448
414449 def drop (self ):
415450 """
@@ -419,12 +454,13 @@ def drop(self):
419454 Tag = self .get_model ('tag' )
420455
421456 session = self .Session ()
457+ try :
458+ session .query (Statement ).delete ()
459+ session .query (Tag ).delete ()
422460
423- session .query (Statement ).delete ()
424- session .query (Tag ).delete ()
425-
426- session .commit ()
427- session .close ()
461+ session .commit ()
462+ finally :
463+ session .close ()
428464
429465 def create_database (self ):
430466 """
@@ -438,5 +474,10 @@ def close(self):
438474 Close the database connection and dispose of the engine.
439475 This ensures proper cleanup of resources.
440476 """
477+ # Remove thread-local sessions from scoped_session registry
478+ if hasattr (self , 'Session' ):
479+ self .Session .remove ()
480+
481+ # Dispose of the connection pool
441482 if hasattr (self , 'engine' ):
442483 self .engine .dispose ()
0 commit comments