259 lines
10 KiB
Python
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
|