Skip to content

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                 )