fadenstack 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- faden_cli/__init__.py +16 -0
- faden_cli/__main__.py +9 -0
- faden_cli/bundle/shared/.gitignore +25 -0
- faden_cli/bundle/shared/LICENSE +187 -0
- faden_cli/bundle/shared/NOTICE +15 -0
- faden_cli/bundle/shared/OFFICIAL.md +9 -0
- faden_cli/bundle/shared/README.md +16 -0
- faden_cli/bundle/shared/agent-binaries/.gitignore +5 -0
- faden_cli/bundle/shared/alloy/config.alloy +40 -0
- faden_cli/bundle/shared/clickhouse/config.d/system-logs.xml +26 -0
- faden_cli/bundle/shared/docker-compose.gpu.yaml +34 -0
- faden_cli/bundle/shared/docker-compose.yaml +998 -0
- faden_cli/bundle/shared/grafana/dashboards/templates/vllm-runtime.json +2099 -0
- faden_cli/bundle/shared/grafana/provisioning/dashboards/dashboard.yml +10 -0
- faden_cli/bundle/shared/grafana/provisioning/dashboards/json/faden-overview.json +211 -0
- faden_cli/bundle/shared/grafana/provisioning/datasources/ds.yaml +9 -0
- faden_cli/bundle/shared/grafana/provisioning/datasources/prometheus.yaml +15 -0
- faden_cli/bundle/shared/initdb/01-init.sql +32 -0
- faden_cli/bundle/shared/loki/local-config.yaml +32 -0
- faden_cli/bundle/shared/nginx/locations.conf +245 -0
- faden_cli/bundle/shared/nginx/maintenance.html +239 -0
- faden_cli/bundle/shared/nginx/nginx-tls.conf +79 -0
- faden_cli/bundle/shared/nginx/nginx.conf +44 -0
- faden_cli/bundle/shared/nginx/rate-limits.conf +42 -0
- faden_cli/bundle/shared/nginx/security-headers.conf +22 -0
- faden_cli/bundle/shared/nginx/select-config.sh +37 -0
- faden_cli/bundle/shared/prometheus/prometheus.yml +40 -0
- faden_cli/bundle/shared/rabbitmq/definitions.json.example +37 -0
- faden_cli/bundle/shared/tls/README.md +30 -0
- faden_cli/cli.py +158 -0
- faden_cli/commands/__init__.py +1 -0
- faden_cli/commands/admin.py +161 -0
- faden_cli/commands/backup.py +159 -0
- faden_cli/commands/config.py +146 -0
- faden_cli/commands/deploy.py +949 -0
- faden_cli/commands/dev/__init__.py +1 -0
- faden_cli/commands/dev/dev_doctor.py +46 -0
- faden_cli/commands/dev/dev_down.py +241 -0
- faden_cli/commands/dev/dev_group.py +24 -0
- faden_cli/commands/dev/dev_init.py +710 -0
- faden_cli/commands/dev/dev_pull.py +297 -0
- faden_cli/commands/dev/dev_status.py +151 -0
- faden_cli/commands/dev/dev_up.py +1392 -0
- faden_cli/commands/doctor.py +225 -0
- faden_cli/commands/down.py +75 -0
- faden_cli/commands/logs_cmd.py +41 -0
- faden_cli/commands/maintenance.py +74 -0
- faden_cli/commands/module.py +113 -0
- faden_cli/commands/observe.py +95 -0
- faden_cli/commands/ports.py +91 -0
- faden_cli/commands/rebuild.py +158 -0
- faden_cli/commands/restore.py +139 -0
- faden_cli/commands/runtime_images.py +174 -0
- faden_cli/commands/status.py +71 -0
- faden_cli/commands/tls.py +722 -0
- faden_cli/commands/tune.py +37 -0
- faden_cli/commands/up.py +64 -0
- faden_cli/commands/upgrade.py +470 -0
- faden_cli/commands/version.py +78 -0
- faden_cli/core/__init__.py +1 -0
- faden_cli/core/api_client.py +77 -0
- faden_cli/core/backup.py +717 -0
- faden_cli/core/bootstrap.py +291 -0
- faden_cli/core/bundle.py +110 -0
- faden_cli/core/clickhouse.py +61 -0
- faden_cli/core/compose.py +484 -0
- faden_cli/core/console.py +106 -0
- faden_cli/core/detect.py +580 -0
- faden_cli/core/dev_modules.py +149 -0
- faden_cli/core/docker_env.py +147 -0
- faden_cli/core/edge.py +94 -0
- faden_cli/core/env_gen.py +598 -0
- faden_cli/core/git.py +219 -0
- faden_cli/core/install.py +288 -0
- faden_cli/core/installation.py +186 -0
- faden_cli/core/local_node.py +719 -0
- faden_cli/core/maintenance.py +82 -0
- faden_cli/core/private_files.py +133 -0
- faden_cli/core/registry.py +574 -0
- faden_cli/core/release_feed.py +342 -0
- faden_cli/core/rmq.py +135 -0
- faden_cli/core/runtime_images.py +309 -0
- faden_cli/core/schedule.py +278 -0
- faden_cli/core/settings.py +159 -0
- faden_cli/core/sysinfo.py +303 -0
- faden_cli/core/tls.py +790 -0
- faden_cli/core/tune.py +154 -0
- faden_cli/core/update_check.py +254 -0
- faden_cli/core/workspace.py +155 -0
- faden_cli/templates/env.j2 +33 -0
- faden_cli/tui/__init__.py +1 -0
- faden_cli/tui/widgets/__init__.py +1 -0
- faden_cli/tui/wizard/__init__.py +1 -0
- fadenstack-0.4.2.dist-info/METADATA +304 -0
- fadenstack-0.4.2.dist-info/RECORD +99 -0
- fadenstack-0.4.2.dist-info/WHEEL +4 -0
- fadenstack-0.4.2.dist-info/entry_points.txt +3 -0
- fadenstack-0.4.2.dist-info/licenses/LICENSE +187 -0
- fadenstack-0.4.2.dist-info/licenses/NOTICE +15 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Configuration file (faden.yaml) read/write utilities.
|
|
2
|
+
|
|
3
|
+
The CLI persists its state in a YAML file located at:
|
|
4
|
+
- ``$FADEN_CONFIG`` env var (if set)
|
|
5
|
+
- ``<install_dir>/faden.yaml``
|
|
6
|
+
- ``%LOCALAPPDATA%/faden/faden.yaml`` (Windows)
|
|
7
|
+
- ``~/.config/faden/faden.yaml`` (Linux/macOS)
|
|
8
|
+
|
|
9
|
+
The schema is intentionally flat: top-level scalars for the common
|
|
10
|
+
case, with a ``dev:`` section for dev-mode specifics.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from faden_cli.core.registry import (
|
|
23
|
+
REPO_DIR_MAP,
|
|
24
|
+
REPO_NAMES,
|
|
25
|
+
repo_clone_url,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Re-export for backward compatibility
|
|
29
|
+
__all__ = ["REPO_DIR_MAP", "repo_clone_url"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Default paths ─────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _default_config_dir() -> Path:
|
|
36
|
+
"""Return platform-appropriate config directory."""
|
|
37
|
+
if os.name == "nt":
|
|
38
|
+
base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
|
|
39
|
+
if base:
|
|
40
|
+
return Path(base) / "faden"
|
|
41
|
+
return Path.home() / ".config" / "faden"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_DEFAULT_CONFIG_DIR = _default_config_dir()
|
|
45
|
+
_DEFAULT_CONFIG_FILE = _DEFAULT_CONFIG_DIR / "faden.yaml"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class DevConfig:
|
|
50
|
+
"""Dev-mode specific configuration."""
|
|
51
|
+
|
|
52
|
+
workspace_dir: str = ""
|
|
53
|
+
clone_method: str = "https" # "https" or "ssh"
|
|
54
|
+
branch: str = "main"
|
|
55
|
+
repos: list[str] = field(default_factory=lambda: list(REPO_NAMES))
|
|
56
|
+
github_token: str = "" # GitHub PAT or OAuth token for private repos
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class FadenConfig:
|
|
61
|
+
"""Top-level CLI configuration."""
|
|
62
|
+
|
|
63
|
+
version: int = 1
|
|
64
|
+
install_dir: str = ""
|
|
65
|
+
compose_file: str = "docker-compose.yaml"
|
|
66
|
+
compose_dev_file: str = "docker-compose.dev.yaml"
|
|
67
|
+
profiles: list[str] = field(default_factory=list)
|
|
68
|
+
admin_email: str = ""
|
|
69
|
+
api_url: str = "http://localhost:8000"
|
|
70
|
+
api_token: str = ""
|
|
71
|
+
#: Which runtime images deploy/upgrade put on this server, by the
|
|
72
|
+
#: architecture of the machines that will run models: "all", "none", or
|
|
73
|
+
#: a list such as "x86_64" or "x86_64,aarch64".
|
|
74
|
+
runtime_images: str = "all"
|
|
75
|
+
#: Whether commands say when a newer release is out (core/update_check.py); deploy asks.
|
|
76
|
+
update_check: bool = True
|
|
77
|
+
#: stable, or preview to hear about release candidates too.
|
|
78
|
+
update_channel: str = "stable"
|
|
79
|
+
#: Where the release feed is read; empty: fadenstack.com. A mirror for sites without internet.
|
|
80
|
+
update_feed_url: str = ""
|
|
81
|
+
dev: DevConfig = field(default_factory=DevConfig)
|
|
82
|
+
|
|
83
|
+
# ── Derived paths ─────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def install_path(self) -> Path:
|
|
87
|
+
"""Resolved install directory."""
|
|
88
|
+
return Path(self.install_dir).expanduser().resolve() if self.install_dir else Path.cwd()
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def compose_path(self) -> Path:
|
|
92
|
+
"""Path to the main docker-compose file."""
|
|
93
|
+
return self.install_path / self.compose_file
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def compose_dev_path(self) -> Path:
|
|
97
|
+
"""Path to the dev overlay docker-compose file."""
|
|
98
|
+
return self.install_path / self.compose_dev_file
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def env_path(self) -> Path:
|
|
102
|
+
"""Path to the .env file."""
|
|
103
|
+
return self.install_path / ".env"
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def dev_workspace_path(self) -> Path:
|
|
107
|
+
"""Resolved dev workspace directory."""
|
|
108
|
+
if self.dev.workspace_dir:
|
|
109
|
+
return Path(self.dev.workspace_dir).expanduser().resolve()
|
|
110
|
+
return Path.cwd()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ── Read/write ────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def config_path() -> Path:
|
|
117
|
+
"""Return the active config file path."""
|
|
118
|
+
env = os.environ.get("FADEN_CONFIG")
|
|
119
|
+
if env:
|
|
120
|
+
return Path(env).expanduser().resolve()
|
|
121
|
+
return _DEFAULT_CONFIG_FILE
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_config() -> FadenConfig:
|
|
125
|
+
"""Load the config from disk, or return defaults if missing."""
|
|
126
|
+
path = config_path()
|
|
127
|
+
if not path.exists():
|
|
128
|
+
return FadenConfig()
|
|
129
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
130
|
+
return _from_dict(data)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def save_config(cfg: FadenConfig) -> Path:
|
|
134
|
+
"""Write the config to disk. Creates parent dirs as needed."""
|
|
135
|
+
path = config_path()
|
|
136
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
path.write_text(yaml.dump(_to_dict(cfg), default_flow_style=False, sort_keys=False), encoding="utf-8")
|
|
138
|
+
return path
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ── Serialisation helpers ─────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _from_dict(data: dict[str, Any]) -> FadenConfig:
|
|
145
|
+
"""Build a ``FadenConfig`` from a raw dict."""
|
|
146
|
+
dev_data = data.pop("dev", {}) or {}
|
|
147
|
+
dev = DevConfig(**{k: v for k, v in dev_data.items() if k in DevConfig.__dataclass_fields__})
|
|
148
|
+
cfg = FadenConfig(
|
|
149
|
+
**{k: v for k, v in data.items() if k in FadenConfig.__dataclass_fields__ and k != "dev"},
|
|
150
|
+
dev=dev,
|
|
151
|
+
)
|
|
152
|
+
return cfg
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _to_dict(cfg: FadenConfig) -> dict[str, Any]:
|
|
156
|
+
"""Serialise a ``FadenConfig`` to a plain dict for YAML."""
|
|
157
|
+
from dataclasses import asdict
|
|
158
|
+
|
|
159
|
+
return asdict(cfg)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""System resource detection and scalability tuning calculator.
|
|
2
|
+
|
|
3
|
+
Uses ``psutil`` to detect host hardware (CPU cores, RAM) and derives
|
|
4
|
+
optimal worker counts, DB pool sizes, and message-queue pool sizes
|
|
5
|
+
for all Fadenstack services.
|
|
6
|
+
|
|
7
|
+
The control plane is sized to use only a fraction of the host
|
|
8
|
+
(``DEFAULT_RESOURCE_PCT``, default 25 %) so that the majority of
|
|
9
|
+
CPU and RAM remain available for inference engines (vLLM, etc.)
|
|
10
|
+
and other workloads.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
import psutil
|
|
20
|
+
|
|
21
|
+
# ── Resource budget ───────────────────────────────────────────────
|
|
22
|
+
# Percentage of total host resources reserved for the Fadenstack
|
|
23
|
+
# control plane. The remainder is left for inference / ML workloads.
|
|
24
|
+
DEFAULT_RESOURCE_PCT: float = 0.25
|
|
25
|
+
|
|
26
|
+
# ── Service classification ────────────────────────────────────────
|
|
27
|
+
# Heavy services handle high-volume request traffic or CPU-bound work.
|
|
28
|
+
# Light services are low-traffic or background-only.
|
|
29
|
+
|
|
30
|
+
HEAVY_SERVICES = ("backend", "gateway", "pii")
|
|
31
|
+
LIGHT_SERVICES: tuple[str, ...] = ()
|
|
32
|
+
ALL_SERVICES = HEAVY_SERVICES + LIGHT_SERVICES
|
|
33
|
+
|
|
34
|
+
# Services whose PostgreSQL pool the CLI sizes. PII has pool settings but
|
|
35
|
+
# opens no connection: its database layer is unused.
|
|
36
|
+
DB_SERVICES = ("backend", "gateway")
|
|
37
|
+
|
|
38
|
+
# Services that use a RabbitMQ connection pool.
|
|
39
|
+
RABBIT_SERVICES = ("backend", "gateway")
|
|
40
|
+
|
|
41
|
+
# ── Pools: per worker process, whatever the number of workers ─────
|
|
42
|
+
# Each worker process keeps its own pools. They were sized by the worker
|
|
43
|
+
# count too -- 3 connections per worker, as many again on demand -- so what a
|
|
44
|
+
# service could open grew with the square of its workers: 24 at 8 cores, 96 at
|
|
45
|
+
# 16, 384 at 32, per service, past any Postgres.
|
|
46
|
+
DB_POOL_PER_WORKER = 5
|
|
47
|
+
DB_OVERFLOW_PER_WORKER = {"dev": 10, "prod": 5}
|
|
48
|
+
RABBIT_POOL_PER_WORKER = 2
|
|
49
|
+
RABBIT_CHANNELS_PER_WORKER = 10
|
|
50
|
+
|
|
51
|
+
# ── Postgres connections ──────────────────────────────────────────
|
|
52
|
+
#: Processes of the backend worker container (taskiq's default), each with
|
|
53
|
+
#: the backend's pool.
|
|
54
|
+
BACKEND_TASK_WORKERS = 2
|
|
55
|
+
#: Services with pools the CLI does not size: 1 worker x (5 + 10).
|
|
56
|
+
UNTUNED_CONNECTIONS = {"mcp": 15, "skills": 15}
|
|
57
|
+
#: Langfuse's web and worker, migrations, backups, psql.
|
|
58
|
+
CONNECTION_HEADROOM = 100
|
|
59
|
+
#: The shared compose file's default; tuning never goes below it.
|
|
60
|
+
MIN_MAX_CONNECTIONS = 250
|
|
61
|
+
|
|
62
|
+
# ── Memory: what each part takes (docker stats, release VM, 2026-09-28) ──
|
|
63
|
+
# Sized by CPU alone, a 7 GB server got two of everything: a model download
|
|
64
|
+
# took the task worker to 2.3 GB, swap filled, the kernel killed the worker,
|
|
65
|
+
# the web backend's supervisor replaced a child that had stopped answering,
|
|
66
|
+
# and at last the VM stopped answering at all. So the workers are cut to
|
|
67
|
+
# what the memory holds. Measured after start-up with every module on; PII
|
|
68
|
+
# loads its language models in each web worker and in its task worker.
|
|
69
|
+
#: The operating system and Docker, and some page cache to breathe: 1 GB plus 5 %.
|
|
70
|
+
OS_RESERVE_MB = 1024
|
|
71
|
+
OS_RESERVE_PCT = 0.05
|
|
72
|
+
#: The services that run once whatever the sizing: the gateway's task worker
|
|
73
|
+
#: ~440, ClickHouse ~290, Postgres ~250, Alloy ~210, the web app ~190,
|
|
74
|
+
#: Grafana ~180, the console ~150, Loki, RabbitMQ (capped at 512), MinIO,
|
|
75
|
+
#: Prometheus, Redis, nginx.
|
|
76
|
+
FIXED_MB = 2100
|
|
77
|
+
#: One worker process of each service ``faden tune`` sizes.
|
|
78
|
+
PER_WORKER_MB = {"backend": 200, "gateway": 290, "pii": 780}
|
|
79
|
+
#: The optional modules' own processes (PII's task worker holds its models too).
|
|
80
|
+
MODULE_MB = {"mcp": 110, "skills": 90, "pii": 1550}
|
|
81
|
+
#: One process of the backend's task worker (taskiq), before any download.
|
|
82
|
+
TASK_WORKER_MB = 160
|
|
83
|
+
#: A model download holds up to one xet range (64 MB) per range it fetches at once.
|
|
84
|
+
XET_RANGE_MB = 64
|
|
85
|
+
#: Ranges fetched at once: fewer on a small server, never so few a download crawls.
|
|
86
|
+
MIN_DOWNLOAD_RANGES, MAX_DOWNLOAD_RANGES = 4, 32
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── System snapshot ───────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class SystemInfo:
|
|
94
|
+
"""Point-in-time snapshot of host hardware resources."""
|
|
95
|
+
|
|
96
|
+
physical_cores: int
|
|
97
|
+
logical_cores: int
|
|
98
|
+
total_ram_bytes: int
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def total_ram_gb(self) -> float:
|
|
102
|
+
return round(self.total_ram_bytes / (1024**3), 1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def detect_system() -> SystemInfo:
|
|
106
|
+
"""Return a :class:`SystemInfo` snapshot of the current host."""
|
|
107
|
+
return SystemInfo(
|
|
108
|
+
physical_cores=psutil.cpu_count(logical=False) or os.cpu_count() or 1,
|
|
109
|
+
logical_cores=psutil.cpu_count(logical=True) or os.cpu_count() or 1,
|
|
110
|
+
total_ram_bytes=psutil.virtual_memory().total,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── Tune profile ─────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class TuneProfile:
|
|
119
|
+
"""Computed scalability parameters for all services."""
|
|
120
|
+
|
|
121
|
+
profile: str # "dev" or "prod"
|
|
122
|
+
system: SystemInfo
|
|
123
|
+
resource_pct: float = DEFAULT_RESOURCE_PCT
|
|
124
|
+
|
|
125
|
+
# Per-service worker counts (service name → count).
|
|
126
|
+
workers: dict[str, int] = field(default_factory=dict)
|
|
127
|
+
|
|
128
|
+
# DB pool sizes (service name → pool_size).
|
|
129
|
+
db_pool_size: dict[str, int] = field(default_factory=dict)
|
|
130
|
+
db_max_overflow: dict[str, int] = field(default_factory=dict)
|
|
131
|
+
|
|
132
|
+
# RabbitMQ pool sizes (service name → pool_size).
|
|
133
|
+
rabbit_pool_size: dict[str, int] = field(default_factory=dict)
|
|
134
|
+
rabbit_channel_pool_size: dict[str, int] = field(default_factory=dict)
|
|
135
|
+
|
|
136
|
+
#: Optional modules switched on ("pii", "mcp", "skills"): they take memory.
|
|
137
|
+
modules: frozenset[str] = frozenset()
|
|
138
|
+
#: Processes of the backend's task worker (taskiq ``--workers``).
|
|
139
|
+
task_workers: int = BACKEND_TASK_WORKERS
|
|
140
|
+
#: Ranges a model download fetches at once (``HF_XET_NUM_CONCURRENT_RANGE_GETS``).
|
|
141
|
+
download_ranges: int = MAX_DOWNLOAD_RANGES
|
|
142
|
+
#: Workers the CPU budget allowed, before the memory had its say.
|
|
143
|
+
cpu_workers: int = 0
|
|
144
|
+
|
|
145
|
+
# ── Prefix map ────────────────────────────────────────────
|
|
146
|
+
_ENV_PREFIX: dict[str, str] = field(
|
|
147
|
+
default_factory=lambda: {
|
|
148
|
+
"backend": "FADEN_BACKEND_",
|
|
149
|
+
"gateway": "FADEN_GATEWAY_",
|
|
150
|
+
"pii": "FADEN_PII_",
|
|
151
|
+
},
|
|
152
|
+
repr=False,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def budget_cores(self) -> int:
|
|
157
|
+
"""Number of CPU cores allocated to the control plane."""
|
|
158
|
+
return max(1, int(self.system.physical_cores * self.resource_pct))
|
|
159
|
+
|
|
160
|
+
def db_connections(self) -> dict[str, int]:
|
|
161
|
+
"""What each database client can open at its peak: workers x (kept + on demand)."""
|
|
162
|
+
per_worker = {svc: self.db_pool_size[svc] + self.db_max_overflow[svc] for svc in DB_SERVICES}
|
|
163
|
+
peaks = {svc: self.workers[svc] * per_worker[svc] for svc in DB_SERVICES}
|
|
164
|
+
peaks["backend-worker"] = self.task_workers * per_worker["backend"]
|
|
165
|
+
peaks.update(UNTUNED_CONNECTIONS)
|
|
166
|
+
return peaks
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def memory_budget_mb(self) -> int:
|
|
170
|
+
"""The memory Fadenstack may take on this host: all of it but the OS reserve."""
|
|
171
|
+
total_mb = self.system.total_ram_bytes // (1024**2)
|
|
172
|
+
return max(0, int(total_mb - OS_RESERVE_MB - total_mb * OS_RESERVE_PCT))
|
|
173
|
+
|
|
174
|
+
def memory_mb(self) -> dict[str, int]:
|
|
175
|
+
"""What each part takes at its sizing, in MB: at rest, and a download under way."""
|
|
176
|
+
parts = {"services that run once": FIXED_MB}
|
|
177
|
+
for module in sorted(self.modules):
|
|
178
|
+
parts[f"{module} module"] = MODULE_MB.get(module, 0)
|
|
179
|
+
for svc in ALL_SERVICES:
|
|
180
|
+
if svc == "pii" and "pii" not in self.modules:
|
|
181
|
+
continue
|
|
182
|
+
parts[f"{svc} x{self.workers[svc]}"] = self.workers[svc] * PER_WORKER_MB[svc]
|
|
183
|
+
parts[f"task worker x{self.task_workers}"] = self.task_workers * TASK_WORKER_MB
|
|
184
|
+
parts["a model download"] = self.download_ranges * XET_RANGE_MB
|
|
185
|
+
return parts
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def memory_short_mb(self) -> int:
|
|
189
|
+
"""How much more memory the smallest sizing needs than this host has; 0 when it fits."""
|
|
190
|
+
return max(0, sum(self.memory_mb().values()) - self.memory_budget_mb)
|
|
191
|
+
|
|
192
|
+
def _fit_memory(self) -> None:
|
|
193
|
+
"""Cut the workers, then the task worker, then the download's ranges, to what the memory holds."""
|
|
194
|
+
def rest(workers: int, task_workers: int) -> int:
|
|
195
|
+
per_set = sum(
|
|
196
|
+
PER_WORKER_MB[svc] for svc in ALL_SERVICES if svc != "pii" or "pii" in self.modules
|
|
197
|
+
)
|
|
198
|
+
modules = sum(MODULE_MB.get(m, 0) for m in self.modules)
|
|
199
|
+
return FIXED_MB + modules + workers * per_set + task_workers * TASK_WORKER_MB
|
|
200
|
+
|
|
201
|
+
budget = self.memory_budget_mb
|
|
202
|
+
smallest_download = MIN_DOWNLOAD_RANGES * XET_RANGE_MB
|
|
203
|
+
workers = max(self.workers.values()) if self.workers else 1
|
|
204
|
+
while workers > 1 and rest(workers, self.task_workers) + smallest_download > budget:
|
|
205
|
+
workers -= 1
|
|
206
|
+
if rest(workers, self.task_workers) + smallest_download > budget:
|
|
207
|
+
self.task_workers = 1
|
|
208
|
+
for svc in HEAVY_SERVICES:
|
|
209
|
+
self.workers[svc] = min(self.workers[svc], workers)
|
|
210
|
+
# A quarter of what is left may go to a download, in whole ranges.
|
|
211
|
+
left = budget - rest(workers, self.task_workers)
|
|
212
|
+
self.download_ranges = max(MIN_DOWNLOAD_RANGES, min(MAX_DOWNLOAD_RANGES, left // 4 // XET_RANGE_MB))
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def max_connections(self) -> int:
|
|
216
|
+
"""Postgres ``max_connections`` for them all, with headroom, in steps of 50."""
|
|
217
|
+
needed = sum(self.db_connections().values()) + CONNECTION_HEADROOM
|
|
218
|
+
return max(MIN_MAX_CONNECTIONS, math.ceil(needed / 50) * 50)
|
|
219
|
+
|
|
220
|
+
def to_env_dict(self) -> dict[str, str]:
|
|
221
|
+
"""Flatten into a dict of ``FADEN_*`` env var → value."""
|
|
222
|
+
env: dict[str, str] = {
|
|
223
|
+
"POSTGRES_MAX_CONNECTIONS": str(self.max_connections),
|
|
224
|
+
# What deploy and upgrade tune with again.
|
|
225
|
+
"FADEN_TUNE_PROFILE": self.profile,
|
|
226
|
+
"FADEN_TUNE_RESOURCE_PCT": str(self.resource_pct),
|
|
227
|
+
# Sized by the memory (see ``_fit_memory``).
|
|
228
|
+
"FADEN_BACKEND_TASK_WORKERS": str(self.task_workers),
|
|
229
|
+
"FADEN_BACKEND_DOWNLOAD_RANGES": str(self.download_ranges),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for svc in ALL_SERVICES:
|
|
233
|
+
pfx = self._ENV_PREFIX[svc]
|
|
234
|
+
env[f"{pfx}WORKERS_COUNT"] = str(self.workers[svc])
|
|
235
|
+
|
|
236
|
+
for svc in DB_SERVICES:
|
|
237
|
+
pfx = self._ENV_PREFIX[svc]
|
|
238
|
+
env[f"{pfx}DB_POOL_SIZE"] = str(self.db_pool_size[svc])
|
|
239
|
+
env[f"{pfx}DB_MAX_OVERFLOW"] = str(self.db_max_overflow[svc])
|
|
240
|
+
|
|
241
|
+
for svc in RABBIT_SERVICES:
|
|
242
|
+
pfx = self._ENV_PREFIX[svc]
|
|
243
|
+
env[f"{pfx}RABBIT_POOL_SIZE"] = str(self.rabbit_pool_size[svc])
|
|
244
|
+
env[f"{pfx}RABBIT_CHANNEL_POOL_SIZE"] = str(
|
|
245
|
+
self.rabbit_channel_pool_size[svc],
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return env
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ── Calculator ────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def calculate_tune_profile(
|
|
255
|
+
profile: str = "dev",
|
|
256
|
+
system: SystemInfo | None = None,
|
|
257
|
+
*,
|
|
258
|
+
resource_pct: float = DEFAULT_RESOURCE_PCT,
|
|
259
|
+
modules: frozenset[str] | set[str] = frozenset(),
|
|
260
|
+
) -> TuneProfile:
|
|
261
|
+
"""Calculate optimal scalability parameters for *profile*.
|
|
262
|
+
|
|
263
|
+
Parameters
|
|
264
|
+
----------
|
|
265
|
+
profile
|
|
266
|
+
``"dev"`` for conservative local defaults, ``"prod"`` for
|
|
267
|
+
production-grade sizing.
|
|
268
|
+
system
|
|
269
|
+
Pre-detected system info. If ``None``, :func:`detect_system`
|
|
270
|
+
is called automatically.
|
|
271
|
+
resource_pct
|
|
272
|
+
Fraction (0.0–1.0) of host CPU to allocate to the control
|
|
273
|
+
plane. Only affects the ``"prod"`` profile. Defaults to
|
|
274
|
+
:data:`DEFAULT_RESOURCE_PCT` (25 %).
|
|
275
|
+
modules
|
|
276
|
+
The optional modules switched on (``pii``, ``mcp``, ``skills``):
|
|
277
|
+
they take memory, and PII's workers are counted only with it on.
|
|
278
|
+
|
|
279
|
+
The CPU sets the number of workers, and the memory then cuts it to what
|
|
280
|
+
the host holds -- see ``TuneProfile._fit_memory``.
|
|
281
|
+
"""
|
|
282
|
+
sys = system or detect_system()
|
|
283
|
+
tp = TuneProfile(profile=profile, system=sys, resource_pct=resource_pct, modules=frozenset(modules))
|
|
284
|
+
|
|
285
|
+
# Workers: dev keeps things light (1-2); prod scales within the CPU
|
|
286
|
+
# budget (resource_pct of the physical cores, e.g. 2 of 8 at 25 %).
|
|
287
|
+
workers = min(sys.physical_cores, 2) if profile == "dev" else tp.budget_cores
|
|
288
|
+
for svc in HEAVY_SERVICES:
|
|
289
|
+
tp.workers[svc] = max(1, workers)
|
|
290
|
+
for svc in LIGHT_SERVICES:
|
|
291
|
+
tp.workers[svc] = 1
|
|
292
|
+
tp.cpu_workers = max(1, workers)
|
|
293
|
+
tp._fit_memory()
|
|
294
|
+
|
|
295
|
+
# Pools: the same per worker process, whatever the number of workers.
|
|
296
|
+
for svc in DB_SERVICES:
|
|
297
|
+
tp.db_pool_size[svc] = DB_POOL_PER_WORKER
|
|
298
|
+
tp.db_max_overflow[svc] = DB_OVERFLOW_PER_WORKER.get(profile, DB_OVERFLOW_PER_WORKER["prod"])
|
|
299
|
+
for svc in RABBIT_SERVICES:
|
|
300
|
+
tp.rabbit_pool_size[svc] = RABBIT_POOL_PER_WORKER
|
|
301
|
+
tp.rabbit_channel_pool_size[svc] = RABBIT_CHANNELS_PER_WORKER
|
|
302
|
+
|
|
303
|
+
return tp
|