Files
bakery-ia/services/tenant/app/services/subscription_cache.py
2025-10-29 06:58:05 +01:00

259 lines
10 KiB
Python

"""
Subscription Cache Service
Provides Redis-based caching for subscription data with 10-minute TTL
"""
import structlog
from typing import Optional, Dict, Any
import json
from datetime import datetime, timezone
from app.repositories import SubscriptionRepository
from app.models.tenants import Subscription
logger = structlog.get_logger()
# Cache TTL in seconds (10 minutes)
SUBSCRIPTION_CACHE_TTL = 600
class SubscriptionCacheService:
"""Service for cached subscription lookups"""
def __init__(self, redis_client=None, database_manager=None):
self.redis = redis_client
self.database_manager = database_manager
async def ensure_database_manager(self):
"""Ensure database manager is properly initialized"""
if self.database_manager is None:
from app.core.config import settings
from shared.database.base import create_database_manager
self.database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
async def get_tenant_tier_cached(self, tenant_id: str) -> str:
"""
Get tenant subscription tier with Redis caching
Args:
tenant_id: Tenant ID
Returns:
Subscription tier (starter, professional, enterprise)
"""
try:
# Ensure database manager is initialized
await self.ensure_database_manager()
cache_key = f"subscription:tier:{tenant_id}"
# Try to get from cache
if self.redis:
try:
cached_tier = await self.redis.get(cache_key)
if cached_tier:
logger.debug("Subscription tier cache hit", tenant_id=tenant_id, tier=cached_tier)
return cached_tier.decode('utf-8') if isinstance(cached_tier, bytes) else cached_tier
except Exception as e:
logger.warning("Redis cache read failed, falling back to database",
tenant_id=tenant_id, error=str(e))
# Cache miss or Redis unavailable - fetch from database
logger.debug("Subscription tier cache miss", tenant_id=tenant_id)
async with self.database_manager.get_session() as db_session:
subscription_repo = SubscriptionRepository(Subscription, db_session)
subscription = await subscription_repo.get_active_subscription(tenant_id)
if not subscription:
logger.warning("No active subscription found, returning starter tier",
tenant_id=tenant_id)
return "starter"
tier = subscription.plan
# Cache the result
if self.redis:
try:
await self.redis.setex(cache_key, SUBSCRIPTION_CACHE_TTL, tier)
logger.debug("Cached subscription tier", tenant_id=tenant_id, tier=tier)
except Exception as e:
logger.warning("Failed to cache subscription tier",
tenant_id=tenant_id, error=str(e))
return tier
except Exception as e:
logger.error("Failed to get subscription tier",
tenant_id=tenant_id, error=str(e))
return "starter" # Fallback to starter on error
async def get_tenant_subscription_cached(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get full tenant subscription with Redis caching
Args:
tenant_id: Tenant ID
Returns:
Subscription data as dictionary or None
"""
try:
# Ensure database manager is initialized
await self.ensure_database_manager()
cache_key = f"subscription:full:{tenant_id}"
# Try to get from cache
if self.redis:
try:
cached_data = await self.redis.get(cache_key)
if cached_data:
logger.debug("Subscription cache hit", tenant_id=tenant_id)
data = json.loads(cached_data.decode('utf-8') if isinstance(cached_data, bytes) else cached_data)
return data
except Exception as e:
logger.warning("Redis cache read failed, falling back to database",
tenant_id=tenant_id, error=str(e))
# Cache miss or Redis unavailable - fetch from database
logger.debug("Subscription cache miss", tenant_id=tenant_id)
async with self.database_manager.get_session() as db_session:
subscription_repo = SubscriptionRepository(Subscription, db_session)
subscription = await subscription_repo.get_active_subscription(tenant_id)
if not subscription:
logger.warning("No active subscription found", tenant_id=tenant_id)
return None
# Convert to dictionary
subscription_data = {
"id": str(subscription.id),
"tenant_id": str(subscription.tenant_id),
"plan": subscription.plan,
"status": subscription.status,
"monthly_price": subscription.monthly_price,
"billing_cycle": subscription.billing_cycle,
"next_billing_date": subscription.next_billing_date.isoformat() if subscription.next_billing_date else None,
"trial_ends_at": subscription.trial_ends_at.isoformat() if subscription.trial_ends_at else None,
"cancelled_at": subscription.cancelled_at.isoformat() if subscription.cancelled_at else None,
"cancellation_effective_date": subscription.cancellation_effective_date.isoformat() if subscription.cancellation_effective_date else None,
"max_users": subscription.max_users,
"max_locations": subscription.max_locations,
"max_products": subscription.max_products,
"features": subscription.features,
"created_at": subscription.created_at.isoformat() if subscription.created_at else None,
"updated_at": subscription.updated_at.isoformat() if subscription.updated_at else None
}
# Cache the result
if self.redis:
try:
await self.redis.setex(
cache_key,
SUBSCRIPTION_CACHE_TTL,
json.dumps(subscription_data)
)
logger.debug("Cached subscription data", tenant_id=tenant_id)
except Exception as e:
logger.warning("Failed to cache subscription data",
tenant_id=tenant_id, error=str(e))
return subscription_data
except Exception as e:
logger.error("Failed to get subscription",
tenant_id=tenant_id, error=str(e))
return None
async def invalidate_subscription_cache(self, tenant_id: str) -> None:
"""
Invalidate subscription cache for a tenant
Args:
tenant_id: Tenant ID
"""
try:
if not self.redis:
logger.debug("Redis not available, skipping cache invalidation",
tenant_id=tenant_id)
return
tier_key = f"subscription:tier:{tenant_id}"
full_key = f"subscription:full:{tenant_id}"
# Delete both cache keys
await self.redis.delete(tier_key, full_key)
logger.info("Invalidated subscription cache",
tenant_id=tenant_id)
except Exception as e:
logger.warning("Failed to invalidate subscription cache",
tenant_id=tenant_id, error=str(e))
async def warm_cache(self, tenant_id: str) -> None:
"""
Pre-warm the cache by loading subscription data
Args:
tenant_id: Tenant ID
"""
try:
logger.debug("Warming subscription cache", tenant_id=tenant_id)
# Load both tier and full subscription to cache
await self.get_tenant_tier_cached(tenant_id)
await self.get_tenant_subscription_cached(tenant_id)
logger.info("Subscription cache warmed", tenant_id=tenant_id)
except Exception as e:
logger.warning("Failed to warm subscription cache",
tenant_id=tenant_id, error=str(e))
# Singleton instance for easy access
_cache_service_instance: Optional[SubscriptionCacheService] = None
def get_subscription_cache_service(redis_client=None) -> SubscriptionCacheService:
"""
Get or create subscription cache service singleton
Args:
redis_client: Optional Redis client
Returns:
SubscriptionCacheService instance
"""
global _cache_service_instance
if _cache_service_instance is None:
from shared.redis_utils import initialize_redis
from app.core.config import settings
import asyncio
# Initialize Redis client if not provided
redis_client_instance = None
if redis_client is None:
try:
loop = asyncio.get_event_loop()
if not loop.is_running():
redis_client_instance = asyncio.run(initialize_redis(settings.REDIS_URL))
else:
# If event loop is running, we can't use asyncio.run
# This is a limitation, but we'll handle it by not initializing Redis here
pass
except:
pass
else:
redis_client_instance = redis_client
_cache_service_instance = SubscriptionCacheService(redis_client=redis_client_instance)
elif redis_client is not None and _cache_service_instance.redis is None:
_cache_service_instance.redis = redis_client
return _cache_service_instance