Improve the frontend 2
This commit is contained in:
258
services/tenant/app/services/subscription_cache.py
Normal file
258
services/tenant/app/services/subscription_cache.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user