"""Concrete, checkable translation of the cPanel account ceiling (Chapter 03). Single source of truth for the account's literal resource limits. Other chapters should import BUDGET rather than re-hardcoding these numbers. validate_budget_config() is called from create_app() so an out-of-budget deployment fails loudly at startup instead of silently degrading under load — this is the concrete mechanism behind Chapter 03's "flag, don't silently accept" requirement. """ from __future__ import annotations from dataclasses import dataclass from flask import Flask @dataclass(frozen=True) class AccountBudget: """The hosting account's literal ceiling (Chapter 03). iops/io_throughput are informational only here — Python config can't enforce them directly; they constrain how Ch06/07 batch reads/writes. """ cpu_cores: int = 4 max_entry_processes: int = 60 memory_mb: int = 2048 iops: int = 1024 io_throughput_mb_s: int = 16 max_total_processes: int = 150 max_db_connections: int = 150 # Derived engineering targets from Chapter 03's budget table. db_pool_size_min: int = 2 db_pool_size_max: int = 5 BUDGET = AccountBudget() _DISALLOWED_CACHE_BACKENDS = {"redis", "rediscache", "memcached", "memcachedcache"} def validate_budget_config(app: Flask) -> None: """Raise at startup if config violates a Chapter 03 rule. Cheap checks only (string/int comparisons) since this runs on every process boot — Passenger may recycle processes frequently (factor 9). """ # Validated as its own config key, not read out of # SQLALCHEMY_ENGINE_OPTIONS — that dict is empty for SQLite (its pool # classes reject pool_size/pool_recycle outright; see app/config.py's # _engine_options_for), so the *intended* setting must be checked # independently of whether the active engine actually consumes it. pool_size = app.config.get("DB_POOL_SIZE") if pool_size is None or not (BUDGET.db_pool_size_min <= pool_size <= BUDGET.db_pool_size_max): raise RuntimeError( f"DB_POOL_SIZE={pool_size} is outside the Chapter 03 budget " f"({BUDGET.db_pool_size_min}-{BUDGET.db_pool_size_max} per process; " f"{BUDGET.max_db_connections} total connections are shared across " f"up to {BUDGET.max_entry_processes} entry processes)." ) cache_type = str(app.config.get("CACHE_TYPE", "")).lower() if any(name in cache_type for name in _DISALLOWED_CACHE_BACKENDS): raise RuntimeError( f"CACHE_TYPE={app.config.get('CACHE_TYPE')!r} assumes a backend " "(Redis/Memcached) Chapter 03 says not to assume is available. " "Use FileSystemCache or a dashboard_cache DB table (Ch06)." ) batch_size = app.config.get("PARSE_BATCH_SIZE") if not batch_size or batch_size <= 0: raise RuntimeError( "PARSE_BATCH_SIZE must be a positive integer — unbounded/whole-file " "parsing per invocation violates the Chapter 03 memory budget." ) max_upload_mb = app.config.get("UPLOAD_MAX_SIZE_MB") if not max_upload_mb or max_upload_mb <= 0: raise RuntimeError( "UPLOAD_MAX_SIZE_MB must be a positive integer to bound disk/IOPS per upload." )