Files
orion/app/services/capacity_forecast_service.py
Samir Boulahtit b9f08b853f refactor: clean up legacy models and migrate remaining schemas
Delete empty stub files from models/database/:
- audit.py, backup.py, configuration.py, monitoring.py
- notification.py, payment.py, search.py, task.py

Delete re-export files:
- models/database/subscription.py → app.modules.billing.models
- models/database/architecture_scan.py → app.modules.dev_tools.models
- models/database/test_run.py → app.modules.dev_tools.models
- models/schema/subscription.py → app.modules.billing.schemas
- models/schema/marketplace.py (empty)
- models/schema/monitoring.py (empty)

Migrate schemas to canonical module locations:
- billing.py → app/modules/billing/schemas/
- vendor_product.py → app/modules/catalog/schemas/
- homepage_sections.py → app/modules/cms/schemas/

Keep as CORE (framework-level, used everywhere):
- models/schema/: admin, auth, base, company, email, image, media, team, vendor*
- models/database/: admin*, base, company, email, feature, media, platform*, user, vendor*

Update 30+ files to use canonical import locations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:45:46 +01:00

322 lines
12 KiB
Python

# app/services/capacity_forecast_service.py
"""
Capacity forecasting service for growth trends and scaling recommendations.
Provides:
- Historical capacity trend analysis
- Growth rate calculations
- Days-until-threshold projections
- Scaling recommendations based on growth patterns
"""
import logging
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.modules.catalog.models import Product
from app.modules.billing.models import (
CapacitySnapshot,
SubscriptionStatus,
VendorSubscription,
)
from models.database.vendor import Vendor, VendorUser
logger = logging.getLogger(__name__)
# Scaling thresholds based on capacity-planning.md
INFRASTRUCTURE_SCALING = [
{"name": "Starter", "max_vendors": 50, "max_products": 10_000, "cost_monthly": 30},
{"name": "Small", "max_vendors": 100, "max_products": 30_000, "cost_monthly": 80},
{"name": "Medium", "max_vendors": 300, "max_products": 100_000, "cost_monthly": 150},
{"name": "Large", "max_vendors": 500, "max_products": 250_000, "cost_monthly": 350},
{"name": "Scale", "max_vendors": 1000, "max_products": 500_000, "cost_monthly": 700},
{"name": "Enterprise", "max_vendors": None, "max_products": None, "cost_monthly": 1500},
]
class CapacityForecastService:
"""Service for capacity forecasting and trend analysis."""
def capture_daily_snapshot(self, db: Session) -> CapacitySnapshot:
"""
Capture a daily snapshot of platform capacity metrics.
Should be called by a daily background job.
"""
from app.services.image_service import image_service
from app.services.platform_health_service import platform_health_service
now = datetime.now(UTC)
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
# Check if snapshot already exists for today
existing = (
db.query(CapacitySnapshot)
.filter(CapacitySnapshot.snapshot_date == today)
.first()
)
if existing:
logger.info(f"Snapshot already exists for {today}")
return existing
# Gather metrics
total_vendors = db.query(func.count(Vendor.id)).scalar() or 0
active_vendors = (
db.query(func.count(Vendor.id))
.filter(Vendor.is_active == True) # noqa: E712
.scalar()
or 0
)
# Subscription metrics
total_subs = db.query(func.count(VendorSubscription.id)).scalar() or 0
active_subs = (
db.query(func.count(VendorSubscription.id))
.filter(VendorSubscription.status.in_(["active", "trial"]))
.scalar()
or 0
)
trial_vendors = (
db.query(func.count(VendorSubscription.id))
.filter(VendorSubscription.status == SubscriptionStatus.TRIAL.value)
.scalar()
or 0
)
# Resource metrics
total_products = db.query(func.count(Product.id)).scalar() or 0
total_team = (
db.query(func.count(VendorUser.id))
.filter(VendorUser.is_active == True) # noqa: E712
.scalar()
or 0
)
# Orders this month
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
total_orders = sum(
s.orders_this_period
for s in db.query(VendorSubscription).all()
)
# Storage metrics
try:
image_stats = image_service.get_storage_stats()
storage_gb = image_stats.get("total_size_gb", 0)
except Exception:
storage_gb = 0
try:
db_size = platform_health_service._get_database_size(db)
except Exception:
db_size = 0
# Theoretical capacity from subscriptions
capacity = platform_health_service.get_subscription_capacity(db)
theoretical_products = capacity["products"].get("theoretical_limit", 0)
theoretical_orders = capacity["orders_monthly"].get("theoretical_limit", 0)
theoretical_team = capacity["team_members"].get("theoretical_limit", 0)
# Tier distribution
tier_distribution = capacity.get("tier_distribution", {})
# Create snapshot
snapshot = CapacitySnapshot(
snapshot_date=today,
total_vendors=total_vendors,
active_vendors=active_vendors,
trial_vendors=trial_vendors,
total_subscriptions=total_subs,
active_subscriptions=active_subs,
total_products=total_products,
total_orders_month=total_orders,
total_team_members=total_team,
storage_used_gb=Decimal(str(storage_gb)),
db_size_mb=Decimal(str(db_size)),
theoretical_products_limit=theoretical_products,
theoretical_orders_limit=theoretical_orders,
theoretical_team_limit=theoretical_team,
tier_distribution=tier_distribution,
)
db.add(snapshot)
db.flush()
db.refresh(snapshot)
logger.info(f"Captured capacity snapshot for {today}")
return snapshot
def get_growth_trends(self, db: Session, days: int = 30) -> dict:
"""
Calculate growth trends over the specified period.
Returns growth rates and projections for key metrics.
"""
now = datetime.now(UTC)
start_date = now - timedelta(days=days)
# Get snapshots for the period
snapshots = (
db.query(CapacitySnapshot)
.filter(CapacitySnapshot.snapshot_date >= start_date)
.order_by(CapacitySnapshot.snapshot_date)
.all()
)
if len(snapshots) < 2:
return {
"period_days": days,
"snapshots_available": len(snapshots),
"trends": {},
"message": "Insufficient data for trend analysis",
}
first = snapshots[0]
last = snapshots[-1]
period_days = (last.snapshot_date - first.snapshot_date).days or 1
def calc_growth(metric: str) -> dict:
start_val = getattr(first, metric) or 0
end_val = getattr(last, metric) or 0
change = end_val - start_val
if start_val > 0:
growth_rate = (change / start_val) * 100
daily_rate = growth_rate / period_days
monthly_rate = daily_rate * 30
else:
growth_rate = 0 if end_val == 0 else 100
daily_rate = 0
monthly_rate = 0
return {
"start_value": start_val,
"current_value": end_val,
"change": change,
"growth_rate_percent": round(growth_rate, 2),
"daily_growth_rate": round(daily_rate, 3),
"monthly_projection": round(end_val * (1 + monthly_rate / 100), 0),
}
trends = {
"vendors": calc_growth("active_vendors"),
"products": calc_growth("total_products"),
"orders": calc_growth("total_orders_month"),
"team_members": calc_growth("total_team_members"),
"storage_gb": {
"start_value": float(first.storage_used_gb or 0),
"current_value": float(last.storage_used_gb or 0),
"change": float((last.storage_used_gb or 0) - (first.storage_used_gb or 0)),
},
}
return {
"period_days": period_days,
"snapshots_available": len(snapshots),
"start_date": first.snapshot_date.isoformat(),
"end_date": last.snapshot_date.isoformat(),
"trends": trends,
}
def get_scaling_recommendations(self, db: Session) -> list[dict]:
"""
Generate scaling recommendations based on current capacity and growth.
Returns prioritized list of recommendations.
"""
from app.services.platform_health_service import platform_health_service
recommendations = []
# Get current capacity
capacity = platform_health_service.get_subscription_capacity(db)
health = platform_health_service.get_full_health_report(db)
trends = self.get_growth_trends(db, days=30)
# Check product capacity
products = capacity["products"]
if products.get("utilization_percent") and products["utilization_percent"] > 80:
recommendations.append({
"category": "capacity",
"severity": "warning",
"title": "Product capacity approaching limit",
"description": f"Currently at {products['utilization_percent']:.0f}% of theoretical product capacity",
"action": "Consider upgrading vendor tiers or adding capacity",
})
# Check infrastructure tier
current_tier = health.get("infrastructure_tier", {})
next_trigger = health.get("next_tier_trigger")
if next_trigger:
recommendations.append({
"category": "infrastructure",
"severity": "info",
"title": f"Current tier: {current_tier.get('name', 'Unknown')}",
"description": f"Next upgrade trigger: {next_trigger}",
"action": "Monitor growth and plan for infrastructure scaling",
})
# Check growth rate
if trends.get("trends"):
vendor_growth = trends["trends"].get("vendors", {})
if vendor_growth.get("monthly_projection", 0) > 0:
monthly_rate = vendor_growth.get("growth_rate_percent", 0)
if monthly_rate > 20:
recommendations.append({
"category": "growth",
"severity": "info",
"title": "High vendor growth rate",
"description": f"Vendor base growing at {monthly_rate:.1f}% over last 30 days",
"action": "Ensure infrastructure can scale to meet demand",
})
# Check storage
storage_percent = health.get("image_storage", {}).get("total_size_gb", 0)
if storage_percent > 800: # 80% of 1TB
recommendations.append({
"category": "storage",
"severity": "warning",
"title": "Storage usage high",
"description": f"Image storage at {storage_percent:.1f} GB",
"action": "Plan for storage expansion or implement cleanup policies",
})
# Sort by severity
severity_order = {"critical": 0, "warning": 1, "info": 2}
recommendations.sort(key=lambda r: severity_order.get(r["severity"], 3))
return recommendations
def get_days_until_threshold(
self, db: Session, metric: str, threshold: int
) -> int | None:
"""
Calculate days until a metric reaches a threshold based on current growth.
Returns None if insufficient data or no growth.
"""
trends = self.get_growth_trends(db, days=30)
if not trends.get("trends") or metric not in trends["trends"]:
return None
metric_data = trends["trends"][metric]
current = metric_data.get("current_value", 0)
daily_rate = metric_data.get("daily_growth_rate", 0)
if daily_rate <= 0 or current >= threshold:
return None
remaining = threshold - current
days = remaining / (current * daily_rate / 100) if current > 0 else None
return int(days) if days else None
# Singleton instance
capacity_forecast_service = CapacityForecastService()