src/genro_asgi_multiworker_spa/orchestration/group_policy.py¶
Source from this local checkout, regenerated when the reader rebuilds.
Line links use #L<number>; a GitHub line range opens its first line.
1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 # https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 """The setpoints of one group, validated once and then immutable.16 17 ``GroupPolicy`` owns the whole schema of what a profile may say about a group:18 the defaults (the same values the ``GroupHandler`` constructor carries), the19 per-key ranges and the cross rules between keys. ``from_settings`` IS the20 validation — it accepts any subset of the keys, fills the rest with the21 defaults and raises ``GroupPolicyError`` listing EVERY violation it found, so22 an invalid policy object cannot exist.23 24 ``null`` in a profile means unlimited or off: ``worker_max_users`` and25 ``user_idle_freeze_minutes`` become ``math.inf`` inside the policy,26 ``cpu_admission_close_percent`` becomes ``None`` (CPU admission policy off),27 ``cpu_offload_percent`` becomes ``None`` (no user is ever offloaded for CPU;28 set, it requires ``cpu_admission_close_percent`` and must sit above it) and29 ``worker_memory_max_percent`` becomes the derivation30 ``100 / worker_max_number``. ``to_settings`` translates all of that back, so31 its output always survives ``json.dumps(..., allow_nan=False)``.32 """33 34 from __future__ import annotations35 36 import math37 from dataclasses import dataclass38 from typing import Any, ClassVar39 40 __all__ = ["PROFILE_VERSION", "STRUCTURAL_KEYS", "GroupPolicy", "GroupPolicyError"]41 42 #: The only profile format this code reads; an absent key means this version.43 PROFILE_VERSION = 144 45 #: Keys of the group recipe that describe processes, not setpoints: they build46 #: the group once and a profile may never carry them.47 STRUCTURAL_KEYS = frozenset(48 {49 "entry_module",50 "executable",51 "worker_class",52 "worker_kwargs",53 "engine_factory",54 "engine_kwargs",55 "main_threadpool_size",56 "aux_threadpool_size",57 }58 )59 60 61 class GroupPolicyError(ValueError):62 """Settings rejected, with the complete list of what is wrong with them."""63 64 def __init__(self, violations: list[str]) -> None:65 self.violations = list(violations)66 super().__init__("; ".join(self.violations))67 68 69 @dataclass(frozen=True)70 class GroupPolicy:71 """One group's setpoints, complete and valid by construction."""72 73 #: key -> (integer, nullable, low bound, low bound exclusive, high bound)74 SETPOINTS: ClassVar[dict[str, tuple[bool, bool, float, bool, float | None]]] = {75 "worker_memory_admission_percent": (False, False, 0.0, False, 100.0),76 "restart_occupancy_max_percent": (False, False, 0.0, False, 100.0),77 "cpu_close_percent": (False, True, 0.0, False, 100.0),78 "cpu_admission_close_percent": (False, True, 0.0, False, 100.0),79 "cpu_admission_reopen_percent": (False, False, 0.0, False, 100.0),80 "cpu_offload_percent": (False, True, 0.0, False, 100.0),81 "cpu_retirement_quiet_seconds": (False, False, 0.0, False, None),82 "cpu_heating_seconds": (False, False, 0.0, True, None),83 "cpu_cooling_seconds": (False, False, 0.0, True, None),84 "worker_admission_interval_seconds": (False, False, 0.0, False, None),85 "worker_min_life_seconds": (False, False, 0.0, False, None),86 "worker_max_users": (True, True, 1, False, None),87 "user_idle_freeze_minutes": (False, True, 0.0, True, None),88 "memory_max_percent": (False, False, 0.0, True, 100.0),89 "worker_max_number": (True, False, 1, False, None),90 "worker_memory_max_percent": (False, True, 0.0, True, None),91 }92 93 worker_memory_admission_percent: float = 80.094 restart_occupancy_max_percent: float = 95.095 cpu_close_percent: float | None = None96 cpu_admission_close_percent: float | None = None97 cpu_admission_reopen_percent: float = 40.098 cpu_offload_percent: float | None = None99 cpu_retirement_quiet_seconds: float = 60.0100 cpu_heating_seconds: float = 1.0101 cpu_cooling_seconds: float = 5.0102 worker_admission_interval_seconds: float = 1.0103 worker_min_life_seconds: float = 60.0104 worker_max_users: float = math.inf105 user_idle_freeze_minutes: float = math.inf106 memory_max_percent: float = 100.0107 worker_max_number: int = 6108 #: What the profile said explicitly; None leaves the derivation in charge.109 worker_memory_max_percent_explicit: float | None = None110 111 @property112 def worker_memory_max_percent(self) -> float:113 """What ONE worker may hold: the explicit value, else the derivation."""114 if self.worker_memory_max_percent_explicit is not None:115 return self.worker_memory_max_percent_explicit116 return 100.0 / self.worker_max_number117 118 def to_settings(self) -> dict[str, Any]:119 """The setpoints as a profile writes them: inf back to null, JSON-safe."""120 settings = {key: getattr(self, key) for key in self.SETPOINTS}121 settings["worker_memory_max_percent"] = self.worker_memory_max_percent_explicit122 for key in ("worker_max_users", "user_idle_freeze_minutes"):123 if settings[key] == math.inf:124 settings[key] = None125 return settings126 127 @classmethod128 def from_settings(cls, settings: dict[str, Any]) -> GroupPolicy:129 """Validate any subset of the setpoints and materialize a complete policy.130 131 Raises GroupPolicyError carrying every violation; never returns a132 partially valid policy.133 """134 violations: list[str] = []135 values: dict[str, Any] = {}136 for key, value in settings.items():137 if key == "profile_version":138 if isinstance(value, bool) or value != PROFILE_VERSION:139 violations.append(140 f"profile_version: only version {PROFILE_VERSION} is supported, "141 f"got {value!r}"142 )143 continue144 if key in STRUCTURAL_KEYS:145 violations.append(f"{key}: structural, not a profile key")146 continue147 if key not in cls.SETPOINTS:148 violations.append(f"{key}: unknown setpoint")149 continue150 cls._check_value(key, value, values, violations)151 for key in ("worker_max_users", "user_idle_freeze_minutes"):152 if values.get(key, math.inf) is None:153 values[key] = math.inf154 if "worker_memory_max_percent" in values:155 values["worker_memory_max_percent_explicit"] = values.pop("worker_memory_max_percent")156 if violations:157 raise GroupPolicyError(violations)158 policy = cls(**values)159 policy._check_cross_rules(violations)160 if violations:161 raise GroupPolicyError(violations)162 return policy163 164 @classmethod165 def _check_value(166 cls,167 key: str,168 value: Any,169 values: dict[str, Any],170 violations: list[str],171 ) -> None:172 """Type, finiteness and range of one setpoint; records it or a violation."""173 integer, nullable, low, low_exclusive, high = cls.SETPOINTS[key]174 if value is None:175 if nullable:176 values[key] = None177 else:178 violations.append(f"{key}: null is not allowed")179 return180 if isinstance(value, bool) or not isinstance(value, int if integer else (int, float)):181 wanted = "an integer" if integer else "a number"182 violations.append(f"{key}: expected {wanted}, got {type(value).__name__}")183 return184 if not math.isfinite(value):185 violations.append(f"{key}: must be a finite number, got {value}")186 return187 below = value <= low if low_exclusive else value < low188 if below or (high is not None and value > high):189 bound = f"{'>' if low_exclusive else '>='} {low}"190 if high is not None:191 bound += f" and <= {high}"192 violations.append(f"{key}: {value} is out of range, must be {bound}")193 return194 values[key] = value195 196 def _check_cross_rules(self, violations: list[str]) -> None:197 """The rules that only the complete policy can answer."""198 if self.worker_memory_admission_percent >= self.restart_occupancy_max_percent:199 violations.append(200 f"worker_memory_admission_percent ({self.worker_memory_admission_percent}) must stay below "201 f"restart_occupancy_max_percent ({self.restart_occupancy_max_percent})"202 )203 if self.cpu_admission_close_percent is not None and not (204 0.0 <= self.cpu_admission_reopen_percent < self.cpu_admission_close_percent <= 100.0205 ):206 violations.append(207 f"cpu_admission_reopen_percent ({self.cpu_admission_reopen_percent}) "208 f"must sit below cpu_admission_close_percent "209 f"({self.cpu_admission_close_percent}), both inside 0-100"210 )211 if (212 self.cpu_close_percent is not None213 and self.cpu_admission_close_percent is not None214 and self.cpu_close_percent > self.cpu_admission_reopen_percent215 ):216 violations.append(217 f"cpu_close_percent ({self.cpu_close_percent}) must not exceed "218 f"cpu_admission_reopen_percent ({self.cpu_admission_reopen_percent}): "219 "a closure must never reopen the growth cycle"220 )221 if self.cpu_offload_percent is not None:222 if self.cpu_admission_close_percent is None:223 violations.append(224 f"cpu_offload_percent ({self.cpu_offload_percent}) requires "225 "cpu_admission_close_percent: the offload stands on the CPU admission closure"226 )227 elif not (self.cpu_admission_close_percent < self.cpu_offload_percent <= 100.0):228 violations.append(229 f"cpu_offload_percent ({self.cpu_offload_percent}) must sit above "230 f"cpu_admission_close_percent ({self.cpu_admission_close_percent}), "231 "inside 0-100"232 )