api: load wildpc.yaml once per listing, not once per item

/services and /jobs called get_wildpc_root() + load_config() *inside* the
loop over registry.all(), so every item paid a full reparse of wildpc.yaml
and all 77 program/deployment YAMLs (~55ms registry + ~103ms config). With
38 services and 12 jobs on this node that was 6.8s and 3.0s per request,
uncached — the dashboard's /services and /scheduled pages each fire one of
these plus /status, which is where their >10s load came from.

Hoist the load to a single _try_load_config() call per request; the source
backfill and the non-deployed pass both read that one config. Responses are
byte-identical to before; both endpoints now serve in ~0.27s.
This commit is contained in:
2026-08-21 10:21:51 -07:00
parent 15957677ff
commit 6acd63cf0a

View File

@@ -233,6 +233,23 @@ def _make_systemd_info(name: str, timer: bool = False) -> SystemdInfo:
return SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=timer) return SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=timer)
def _try_load_config(root: Path | None) -> object | None:
"""Load wildpc.yaml for `root`, or None when there's no config to read.
Listing endpoints call this **once** and reuse the result: load_config()
reparses every program/deployment YAML, so a per-item load turns a listing
into an N x full-config-parse (seconds, not milliseconds).
"""
if root is None:
return None
from wildpc_core.config import load_config
try:
return load_config(root)
except FileNotFoundError:
return None
def _backfill_source(name: str, config: object) -> str | None: def _backfill_source(name: str, config: object) -> str | None:
"""Resolve source path from program ref in config.""" """Resolve source path from program ref in config."""
if name in config.programs: if name in config.programs:
@@ -429,6 +446,10 @@ def list_services(include_remote: bool = False) -> list[ServiceSummary]:
summaries: list[ServiceSummary] = [] summaries: list[ServiceSummary] = []
seen: set[str] = set() seen: set[str] = set()
# One config load for the whole listing — the source backfill and the
# non-deployed pass below both read it.
config = _try_load_config(get_wildpc_root())
# Services page shows services (systemd) AND statics (caddy) — both are # Services page shows services (systemd) AND statics (caddy) — both are
# exposed, URL-reachable "services". Not jobs, tools, or remotes. # exposed, URL-reachable "services". Not jobs, tools, or remotes.
for _kind, name, deployed in registry.all(): for _kind, name, deployed in registry.all():
@@ -437,33 +458,19 @@ def list_services(include_remote: bool = False) -> list[ServiceSummary]:
s = _service_from_deployed(name, deployed) s = _service_from_deployed(name, deployed)
s.node = hostname s.node = hostname
# Backfill source # Backfill source
root = get_wildpc_root() if config is not None and s.source is None:
if root and s.source is None: s.source = _backfill_source(name, config)
try:
from wildpc_core.config import load_config
config = load_config(root)
s.source = _backfill_source(name, config)
except FileNotFoundError:
pass
summaries.append(s) summaries.append(s)
seen.add(name) seen.add(name)
# Non-deployed from wildpc.yaml # Non-deployed from wildpc.yaml
root = get_wildpc_root() if config is not None:
if root: for name, svc in config.services.items():
try: if name not in seen:
from wildpc_core.config import load_config s = _service_from_spec(name, svc, config)
s.node = hostname
config = load_config(root) summaries.append(s)
for name, svc in config.services.items(): seen.add(name)
if name not in seen:
s = _service_from_spec(name, svc, config)
s.node = hostname
summaries.append(s)
seen.add(name)
except FileNotFoundError:
pass
# Remote # Remote
if include_remote: if include_remote:
@@ -553,39 +560,28 @@ def list_jobs(include_remote: bool = False) -> list[JobSummary]:
summaries: list[JobSummary] = [] summaries: list[JobSummary] = []
seen: set[str] = set() seen: set[str] = set()
# One config load for the whole listing (see list_services).
config = _try_load_config(get_wildpc_root())
# Deployed jobs (scheduled) # Deployed jobs (scheduled)
for _kind, name, deployed in registry.all(): for _kind, name, deployed in registry.all():
if not deployed.schedule: if not deployed.schedule:
continue continue
s = _job_from_deployed(name, deployed) s = _job_from_deployed(name, deployed)
s.node = hostname s.node = hostname
root = get_wildpc_root() if config is not None and s.source is None:
if root and s.source is None: s.source = _backfill_source(name, config)
try:
from wildpc_core.config import load_config
config = load_config(root)
s.source = _backfill_source(name, config)
except FileNotFoundError:
pass
summaries.append(s) summaries.append(s)
seen.add(name) seen.add(name)
# Non-deployed from wildpc.yaml # Non-deployed from wildpc.yaml
root = get_wildpc_root() if config is not None:
if root: for name, job in config.jobs.items():
try: if name not in seen:
from wildpc_core.config import load_config s = _job_from_spec(name, job, config)
s.node = hostname
config = load_config(root) summaries.append(s)
for name, job in config.jobs.items(): seen.add(name)
if name not in seen:
s = _job_from_spec(name, job, config)
s.node = hostname
summaries.append(s)
seen.add(name)
except FileNotFoundError:
pass
# Remote # Remote
if include_remote: if include_remote: