Skip to content

Commit de89fe6

Browse files
authored
Add defaults for connection management (#2432)
1 parent 354cd1d commit de89fe6

1 file changed

Lines changed: 153 additions & 112 deletions

File tree

chatterbot/storage/sql_storage.py

Lines changed: 153 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)