Files
computer-playbook/plugins/lookup/resource.py
T
kevinveenbirkenbachandClaude Opus 5 7690d2ca6d fix(postgres): raise max_locks_per_transaction to 256
GitLab's migration on its dedicated postgres sidecar failed in CI run
35674334334 with "ERROR: out of shared memory" and the hint to raise
max_locks_per_transaction. The default of 64 locks per transaction cannot
cover a schema that locks thousands of tables and indexes in one
transaction: a reproduction on postgres:18 failed at table 7453 with the
default and held 20000 tables at 256.

The value sits next to the other postgres tuning constants in the
resource lookup, is exposed as POSTGRES_MAX_LOCKS_PER_TRANSACTION, and
reaches both the central svc-db-postgres command and the per-app sidecar.
The sidecar's new command keeps the image entrypoint and only adds the
flag. The shared lock table grows to about 256 x max_connections entries,
roughly 30 MB at the 400-connection cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 15:52:16 +02:00

274 lines
9.5 KiB
Python

from __future__ import annotations
import os
from decimal import ROUND_HALF_UP, Decimal
from pathlib import Path
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.lookup import LookupBase
from plugins.filter.active_docker import active_docker_container_count
from plugins.filter.split_postgres_connections import split_postgres_connections
from utils.cache.errors import UnresolvableValueError
from utils.env.runtime import mem_total_mb
_CPUS_OVERRIDE = "RESOURCE_HOST_CPUS_OVERRIDE"
_MEM_MB_OVERRIDE = "RESOURCE_HOST_MEM_MB_OVERRIDE"
_HOST_RESERVE_CPU = 2
_HOST_RESERVE_MEM = 4
_PIDS_LIMIT = 512
_POSTGRES_SUPERUSER_RESERVED_CONNECTIONS = 3
_POSTGRES_DELAY = 2
_POSTGRES_MAX_LOCKS_PER_TRANSACTION = 256
_CONTAINER_PREFIX_REGEX = r"^(web-|svc-).*"
_VALUES_CACHE: dict[tuple[int, int, int | None, str], dict[str, Any]] = {}
_SHARE_KEYS = frozenset(
{
"active_docker_container_count",
"cpus",
"cpus_num",
"mem_limit",
"mem_limit_num",
"mem_reservation",
"mem_reservation_num",
}
)
def _round_half_up(value: float, digits: int) -> float:
"""Jinja's ``round`` rounds half away from zero, Python's rounds to even.
The two disagree on every exact .5, so the port would silently resize
containers on those hosts without this.
"""
quantum = Decimal(1).scaleb(-digits)
return float(Decimal(repr(value)).quantize(quantum, rounding=ROUND_HALF_UP))
def _positive_int(value: Any) -> int | None:
try:
number = int(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None
class _HostResources:
"""The two measured values, resolved once per lookup call."""
def __init__(self, variables: dict[str, Any]) -> None:
self._vars = variables
self._facts = variables.get("ansible_facts") or {}
def _is_local_target(self) -> bool:
return self._vars.get("ansible_connection") == "local"
def _unmeasurable(self, message: str) -> Exception:
"""The error class, chosen by whether a host is being deployed.
A play carries `inventory_hostname`, and there a value nobody could
measure has to stop the run: `UnresolvableValueError` is the one class
the render layers re-raise instead of swallowing.
Static renders carry no host. The inventory generator and the lint suite
both walk the whole applications payload without deploying anything, and
the meta files of nextcloud and svc-opt-swapfile sit in that payload.
Aborting there would fail tooling over a value it never reads, so the
error stays an ordinary `AnsibleError` that best-effort rendering
absorbs, exactly as the missing fact did before.
"""
if self._vars.get("inventory_hostname"):
return UnresolvableValueError(message)
return AnsibleError(message)
def _resolve(
self,
override_key: str,
fact_key: str,
probe: Any,
unit: str,
) -> int:
override = _positive_int(self._vars.get(override_key))
if override is not None:
return override
from_fact = _positive_int(self._facts.get(fact_key))
if from_fact is not None:
return from_fact
if not self._is_local_target():
raise self._unmeasurable(
f"resource: cannot determine the host {unit} of "
f"'{self._vars.get('inventory_hostname', '<unknown host>')}'. "
f"Its hardware facts are missing and the probe would measure the "
f"Ansible controller instead of that host. "
f"Set '{override_key}: <{unit}>' in the inventory."
)
measured = _positive_int(probe())
if measured is None:
raise self._unmeasurable(
f"resource: probing the host {unit} returned no usable value. "
f"Set '{override_key}: <{unit}>' in the inventory."
)
return measured
@property
def cpus(self) -> int:
return self._resolve(_CPUS_OVERRIDE, "processor_vcpus", os.cpu_count, "CPUs")
@property
def mem_mb(self) -> int:
return self._resolve(_MEM_MB_OVERRIDE, "memtotal_mb", mem_total_mb, "MiB")
def _container_count(variables: dict[str, Any]) -> int:
return active_docker_container_count(
variables.get("applications") or {},
variables.get("group_names") or [],
_CONTAINER_PREFIX_REGEX,
ensure_min_one=True,
)
def _build(
variables: dict[str, Any],
roles_dir: Path,
containers: int | None,
) -> dict[str, Any]:
"""Values for one host.
`containers` stays None unless a requested key needs it. Reading
`applications` pulls the whole merged payload through the templar, and the
meta files of nextcloud and svc-opt-swapfile read their memory back out of
this very lookup, so computing it unconditionally would make that render
re-enter itself.
"""
host = _HostResources(variables)
host_cpus = host.cpus
host_mem_mb = host.mem_mb
cache_key = (host_cpus, host_mem_mb, containers, str(roles_dir))
cached = _VALUES_CACHE.get(cache_key)
if cached is not None:
return cached
host_mem_gb = host_mem_mb // 1024
avail_cpus = host_cpus - _HOST_RESERVE_CPU
avail_mem = host_mem_gb - _HOST_RESERVE_MEM
max_connections = min(host_cpus * 30 + 50, 400)
shared_buffers_mb = host_mem_mb * 25 // 100
work_mem_mb = max(host_mem_mb // max(max_connections, 1) // 2, 1)
maintenance_work_mem_mb = max(host_mem_mb * 5 // 100, 64)
values = {
"host_cpus": host_cpus,
"host_mem_mb": host_mem_mb,
"host_mem": host_mem_gb,
"host_reserve_cpu": _HOST_RESERVE_CPU,
"host_reserve_mem": _HOST_RESERVE_MEM,
"avail_cpus": avail_cpus,
"avail_mem": avail_mem,
"pids_limit": _PIDS_LIMIT,
"postgres_vcpus": host_cpus,
"postgres_total_ram_mb": host_mem_mb,
"postgres_max_connections": max_connections,
"postgres_allowed_avg_connections": int(
split_postgres_connections(max_connections, str(roles_dir))
),
"postgres_superuser_reserved_connections": (
_POSTGRES_SUPERUSER_RESERVED_CONNECTIONS
),
"postgres_shared_buffers_mb": shared_buffers_mb,
"postgres_shared_buffers": f"{shared_buffers_mb}MB",
"postgres_work_mem_mb": work_mem_mb,
"postgres_work_mem": f"{work_mem_mb}MB",
"postgres_maintenance_work_mem_mb": maintenance_work_mem_mb,
"postgres_maintenance_work_mem": f"{maintenance_work_mem_mb}MB",
"postgres_delay": _POSTGRES_DELAY,
"postgres_max_locks_per_transaction": _POSTGRES_MAX_LOCKS_PER_TRANSACTION,
}
if containers is not None:
cpus_num = max(_round_half_up(avail_cpus / containers, 2), 0.5)
mem_reservation_num = _round_half_up(avail_mem / containers * 0.7, 1)
mem_limit_num = _round_half_up(avail_mem / containers * 1.0, 1)
values.update(
{
"active_docker_container_count": containers,
"cpus_num": cpus_num,
"cpus": cpus_num,
"mem_reservation_num": mem_reservation_num,
"mem_reservation": f"{mem_reservation_num}g",
"mem_limit_num": mem_limit_num,
"mem_limit": f"{mem_limit_num}g",
}
)
_VALUES_CACHE[cache_key] = values
return values
class LookupModule(LookupBase):
"""
Resolve one host-resource value, measured and computed in one place.
Usage:
lookup('resource', 'host_cpus')
lookup('resource', 'mem_limit')
query('resource', ['cpus', 'mem_limit'])
The two measured inputs resolve in a fixed order: an inventory override
(`RESOURCE_HOST_CPUS_OVERRIDE` / `RESOURCE_HOST_MEM_MB_OVERRIDE`), then the
host's own hardware facts, then a probe of the machine this plugin runs on.
The probe only applies to a target reached over the local connection,
because a lookup executes on the Ansible controller: probing for a remote
host would report the controller's hardware. That case raises and names the
override to set.
No value is ever substituted. Ansible's Linux hardware collector reads
`/sys/block/<dev>/size` without a None guard and takes every hardware fact
with it when that read fails, which is why the facts are a preferred source
here rather than the only one.
Optional kwarg:
roles_dir — roles directory for the postgres connection split
(default: <cwd>/roles, matching the service lookup).
"""
def run(
self,
terms: list[Any],
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[Any]:
if not terms:
return []
if len(terms) == 1 and isinstance(terms[0], (list, tuple)):
terms = list(terms[0])
vars_ = variables or getattr(self._templar, "available_variables", {}) or {}
roles_dir = Path(kwargs.get("roles_dir") or Path.cwd() / "roles")
keys = [str(term).strip() for term in terms]
containers = _container_count(vars_) if _SHARE_KEYS.intersection(keys) else None
values = _build(vars_, roles_dir, containers)
results: list[Any] = []
for key in keys:
if key not in values:
known = sorted(set(values) | _SHARE_KEYS)
raise AnsibleError(
f"resource: unknown key '{key}'. Valid keys: {', '.join(known)}."
)
results.append(values[key])
return results