Skip to content

Commit 79f68ae

Browse files
2 parents 7825ee4 + 3fb0167 commit 79f68ae

13 files changed

Lines changed: 844 additions & 235 deletions

File tree

‎backend/app.py‎

Lines changed: 216 additions & 206 deletions
Large diffs are not rendered by default.

‎backend/cache_service.py‎

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,26 @@
1212
from functools import wraps
1313
from datetime import datetime, timedelta
1414
from enum import Enum
15+
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
1516
from backend.config import app_config
1617

1718
try:
1819
import redis
1920
from flask_caching import Cache
2021
REDIS_AVAILABLE = True
22+
REDIS_EXCEPTIONS = (redis.exceptions.TimeoutError, redis.exceptions.ConnectionError)
2123
except ImportError:
2224
REDIS_AVAILABLE = False
25+
REDIS_EXCEPTIONS = ()
26+
27+
def with_redis_retry():
28+
"""Retry decorator for Redis transient failures."""
29+
return retry(
30+
stop=stop_after_attempt(3),
31+
wait=wait_exponential(multiplier=1, min=1, max=5),
32+
retry=retry_if_exception_type(REDIS_EXCEPTIONS),
33+
reraise=True
34+
)
2335

2436
logger = logging.getLogger(__name__)
2537

@@ -238,18 +250,22 @@ def get(self, key: str) -> Optional[Any]:
238250
if not self.cache:
239251
return None
240252

253+
@with_redis_retry()
254+
def _execute_get():
255+
return self.cache.get(key)
256+
241257
try:
242-
value = self.cache.get(key)
258+
value = _execute_get()
243259
if value is not None:
244260
self.cache_stats['hits'] += 1
245261
logger.debug(f"Cache HIT for: {key}")
246262
else:
247263
self.cache_stats['misses'] += 1
248264
logger.debug(f"Cache MISS for: {key}")
249265
return value
250-
except (redis.exceptions.TimeoutError, redis.exceptions.ConnectionError) as e:
266+
except REDIS_EXCEPTIONS as e:
251267
self.cache_stats['errors'] += 1
252-
logger.error(f"Redis error during GET '{key}': {e}")
268+
logger.error(f"Redis error during GET '{key}' after retries: {e}")
253269
return None
254270
except Exception as e:
255271
self.cache_stats['errors'] += 1
@@ -263,13 +279,17 @@ def set(self, key: str, value: Any, timeout: Optional[int] = None) -> bool:
263279
if not self.cache:
264280
return False
265281

266-
try:
282+
@with_redis_retry()
283+
def _execute_set():
267284
self.cache.set(key, value, timeout=timeout)
285+
286+
try:
287+
_execute_set()
268288
logger.debug(f"Cache SET for: {key} (TTL: {timeout})")
269289
return True
270-
except (redis.exceptions.TimeoutError, redis.exceptions.ConnectionError) as e:
290+
except REDIS_EXCEPTIONS as e:
271291
self.cache_stats['errors'] += 1
272-
logger.error(f"Redis error during SET '{key}': {e}")
292+
logger.error(f"Redis error during SET '{key}' after retries: {e}")
273293
return False
274294
except Exception as e:
275295
self.cache_stats['errors'] += 1
@@ -283,10 +303,18 @@ def delete(self, key: str) -> bool:
283303
if not self.cache:
284304
return False
285305

286-
try:
306+
@with_redis_retry()
307+
def _execute_delete():
287308
self.cache.delete(key)
309+
310+
try:
311+
_execute_delete()
288312
logger.debug(f"Cache DELETE for: {key}")
289313
return True
314+
except REDIS_EXCEPTIONS as e:
315+
self.cache_stats['errors'] += 1
316+
logger.error(f"Redis error during DELETE '{key}' after retries: {e}")
317+
return False
290318
except Exception as e:
291319
self.cache_stats['errors'] += 1
292320
logger.error(f"Cache error during DELETE '{key}': {e}")
@@ -301,7 +329,8 @@ def clear_namespace(self, namespace: Union[CacheNamespace, str], identifier: Opt
301329
logger.warning("Namespaced clearing requires an active Redis connection.")
302330
return 0
303331

304-
try:
332+
@with_redis_retry()
333+
def _execute_clear():
305334
ns_val = namespace.value if isinstance(namespace, CacheNamespace) else namespace
306335
if identifier:
307336
pattern = f"{CacheConfig.GLOBAL_PREFIX}:{CacheConfig.CACHE_VERSION}:{ns_val}:{identifier}:*"
@@ -314,6 +343,12 @@ def clear_namespace(self, namespace: Union[CacheNamespace, str], identifier: Opt
314343
logger.info(f"Cleared {deleted_count} keys from namespace '{ns_val}' pattern '{pattern}'")
315344
return deleted_count
316345
return 0
346+
347+
try:
348+
return _execute_clear()
349+
except REDIS_EXCEPTIONS as e:
350+
logger.error(f"Redis error clearing namespace '{namespace}' after retries: {e}")
351+
return 0
317352
except Exception as e:
318353
logger.error(f"Error clearing namespace '{namespace}': {e}")
319354
return 0
@@ -353,8 +388,12 @@ def get_memory_usage(self) -> Dict[str, Any]:
353388
if not self.redis_client:
354389
return {"error": "Redis client not available"}
355390

391+
@with_redis_retry()
392+
def _execute_info():
393+
return self.redis_client.info(section='memory')
394+
356395
try:
357-
info = self.redis_client.info(section='memory')
396+
info = _execute_info()
358397
return {
359398
'used_memory_human': info.get('used_memory_human'),
360399
'used_memory_rss_human': info.get('used_memory_rss_human'),
@@ -363,6 +402,9 @@ def get_memory_usage(self) -> Dict[str, Any]:
363402
'maxmemory_human': info.get('maxmemory_human'),
364403
'maxmemory_policy': info.get('maxmemory_policy')
365404
}
405+
except REDIS_EXCEPTIONS as e:
406+
logger.error(f"Redis error getting memory info after retries: {e}")
407+
return {"error": f"Redis error: {e}"}
366408
except Exception as e:
367409
logger.error(f"Failed to get Redis memory info: {e}")
368410
return {"error": str(e)}
@@ -371,8 +413,16 @@ def get_key_count(self) -> int:
371413
"""Return the total number of keys currently in the Redis database."""
372414
if not self.redis_client:
373415
return 0
374-
try:
416+
417+
@with_redis_retry()
418+
def _execute_dbsize():
375419
return self.redis_client.dbsize()
420+
421+
try:
422+
return _execute_dbsize()
423+
except REDIS_EXCEPTIONS as e:
424+
logger.error(f"Redis error getting key count after retries: {e}")
425+
return 0
376426
except Exception as e:
377427
logger.error(f"Failed to get Redis key count: {e}")
378428
return 0

‎backend/config.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,15 @@ def __init__(self):
461461

462462
# Disable rate limiting for tests
463463
self.rate_limit.enabled = False
464+
465+
# Ensure tests don't use real API credits
466+
self.ai_service.openai_api_key = 'test-dummy-openai-key'
467+
self.ai_service.groq_api_key = 'test-dummy-groq-key'
468+
self.ai_service.gemini_api_key = 'test-dummy-gemini-key'
469+
self.ai_service.google_books_api_key = 'test-dummy-google-books-key'
470+
self.email.api_key = 'test-dummy-email-key'
471+
self.storage.access_key = 'test-dummy-storage-access'
472+
self.storage.secret_key = 'test-dummy-storage-secret'
464473

465474

466475
def get_config() -> Config:

‎backend/models.py‎

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,11 @@ class PasswordResetToken(db.Model):
141141
used_at = db.Column(db.DateTime, nullable=True)
142142
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
143143

144-
user = db.relationship('User', backref=db.backref('password_reset_tokens', lazy='dynamic'))
144+
user = db.relationship(
145+
'User',
146+
backref=db.backref('password_reset_tokens', lazy='dynamic'),
147+
lazy='joined'
148+
)
145149

146150

147151
class Book(db.Model, SoftDeleteMixin):
@@ -306,9 +310,22 @@ def validate_shelf_type(self, key, value):
306310
return value
307311

308312
# Relationships
309-
user = db.relationship('User', backref=db.backref('shelf_items', lazy=True))
310-
book = db.relationship('Book', backref=db.backref('shelf_items', lazy=True))
311-
price_alerts = db.relationship('PriceAlert', backref='shelf_item', lazy=True, cascade='all, delete-orphan')
313+
user = db.relationship(
314+
'User',
315+
backref=db.backref('shelf_items', lazy='subquery'),
316+
lazy='joined'
317+
)
318+
book = db.relationship(
319+
'Book',
320+
backref=db.backref('shelf_items', lazy='subquery'),
321+
lazy='joined'
322+
)
323+
price_alerts = db.relationship(
324+
'PriceAlert',
325+
backref=db.backref('shelf_item', lazy='joined'),
326+
lazy='subquery',
327+
cascade='all, delete-orphan'
328+
)
312329

313330
# =========================================================================
314331
# DATABASE LEVEL CONSTRAINTS
@@ -403,7 +420,11 @@ class ReadingGoal(db.Model, SoftDeleteMixin):
403420
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
404421

405422
# Relationships
406-
user = db.relationship('User', backref=db.backref('reading_goals', lazy=True))
423+
user = db.relationship(
424+
'User',
425+
backref=db.backref('reading_goals', lazy='subquery'),
426+
lazy='joined'
427+
)
407428

408429
__table_args__ = (
409430
db.UniqueConstraint('user_id', 'year', name='uq_user_year_goal'),
@@ -434,7 +455,11 @@ class ReadingStats(db.Model, SoftDeleteMixin):
434455
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
435456

436457
# Relationships
437-
user = db.relationship('User', backref=db.backref('reading_stats', lazy=True))
458+
user = db.relationship(
459+
'User',
460+
backref=db.backref('reading_stats', lazy='subquery'),
461+
lazy='joined'
462+
)
438463

439464
__table_args__ = (
440465
db.UniqueConstraint('user_id', 'year', 'month', name='uq_user_year_month_stats'),
@@ -466,8 +491,17 @@ class Collection(db.Model, SoftDeleteMixin):
466491
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
467492

468493
# Relationships
469-
user = db.relationship('User', backref=db.backref('collections', lazy=True))
470-
items = db.relationship('CollectionItem', backref='collection', lazy=True, cascade='all, delete-orphan')
494+
user = db.relationship(
495+
'User',
496+
backref=db.backref('collections', lazy='subquery'),
497+
lazy='joined'
498+
)
499+
items = db.relationship(
500+
'CollectionItem',
501+
backref=db.backref('collection', lazy='joined'),
502+
lazy='subquery',
503+
cascade='all, delete-orphan'
504+
)
471505

472506
__table_args__ = (
473507
db.UniqueConstraint('user_id', 'name', name='uq_user_collection_name'),
@@ -499,7 +533,11 @@ class CollectionItem(db.Model, SoftDeleteMixin):
499533
added_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
500534

501535
# Relationships
502-
book = db.relationship('Book', backref=db.backref('collection_items', lazy=True))
536+
book = db.relationship(
537+
'Book',
538+
backref=db.backref('collection_items', lazy='subquery'),
539+
lazy='joined'
540+
)
503541

504542
__table_args__ = (
505543
db.UniqueConstraint('collection_id', 'book_id', name='uq_collection_book'),
@@ -565,7 +603,11 @@ class PriceHistory(db.Model, SoftDeleteMixin):
565603
checked_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
566604

567605
# Relationships
568-
book = db.relationship('Book', backref=db.backref('price_history', lazy=True))
606+
book = db.relationship(
607+
'Book',
608+
backref=db.backref('price_history', lazy='subquery'),
609+
lazy='joined'
610+
)
569611

570612
__table_args__ = (
571613
db.Index('idx_price_history_book_retailer', 'book_id', 'retailer'),
@@ -599,7 +641,11 @@ class PriceAlert(db.Model, SoftDeleteMixin):
599641
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
600642

601643
# Relationships
602-
user = db.relationship('User', backref=db.backref('price_alerts', lazy=True))
644+
user = db.relationship(
645+
'User',
646+
backref=db.backref('price_alerts', lazy='subquery'),
647+
lazy='joined'
648+
)
603649

604650
__table_args__ = (
605651
db.UniqueConstraint('user_id', 'shelf_item_id', name='uq_user_shelf_item_alert'),
@@ -638,8 +684,16 @@ class Review(db.Model, SoftDeleteMixin):
638684
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
639685

640686
# Relationships
641-
user = db.relationship('User', backref=db.backref('reviews', lazy=True))
642-
book = db.relationship('Book', backref=db.backref('reviews', lazy=True))
687+
user = db.relationship(
688+
'User',
689+
backref=db.backref('reviews', lazy='subquery'),
690+
lazy='joined'
691+
)
692+
book = db.relationship(
693+
'Book',
694+
backref=db.backref('reviews', lazy='subquery'),
695+
lazy='joined'
696+
)
643697

644698
__table_args__ = (
645699
db.UniqueConstraint('user_id', 'book_id', name='uq_user_book_review'),
@@ -681,8 +735,16 @@ class JournalEntry(db.Model, SoftDeleteMixin):
681735
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
682736

683737
# Relationships
684-
user = db.relationship('User', backref=db.backref('journal_entries', lazy=True))
685-
book = db.relationship('Book', backref=db.backref('journal_entries', lazy=True))
738+
user = db.relationship(
739+
'User',
740+
backref=db.backref('journal_entries', lazy='subquery'),
741+
lazy='joined'
742+
)
743+
book = db.relationship(
744+
'Book',
745+
backref=db.backref('journal_entries', lazy='subquery'),
746+
lazy='joined'
747+
)
686748

687749
def to_dict(self):
688750
return {

0 commit comments

Comments
 (0)