"""Прореживание точек истории с сохранением крайностей.
Общий код для всех мест, где точки приходится выбрасывать: ответ
`GET /servers/{id}/metrics` (страница сервера) и MCP-тул `server_history`.
Точка, оставшаяся от группы, несёт дайджест `agg` всей группы — min/max по
метрикам и n/exp замеров (формат и смысл — в docstring `metrics_retention`);
без этого прореживание съедало бы ровно те всплески и простои, ради которых
дайджест и хранится.
Прореживание в БД (по возрасту точки) живёт отдельно — в
`metrics_retention.thin_once`, потому что считает бакеты по календарю, а не по
числу точек; здесь же группы нарезаются равномерно по окну ответа.
"""
def thin_points(points: list[dict], max_points: int) -> list[dict]:
"""Проредить точки (по возрастанию ts) до max_points, сливая их дайджесты.
В каждой группе остаётся последняя точка — самая свежая, поэтому свежий
край окна не теряется; в её `agg` собираются крайности всей группы.
"""
step = len(points) / max_points
out = []
for i in range(max_points):
lo = int(i * step)
hi = int((i + 1) * step) if i + 1 < max_points else len(points)
if lo >= hi:
break # вырожденные слоты возможны только при max_points > len(points)
group = points[lo:hi]
point = dict(group[-1])
digest: dict = {}
for member in group:
merge_digest(digest, member.get("agg"))
if digest:
point["agg"] = digest
out.append(point)
return out
def merge_digest(acc: dict, agg: dict | None) -> dict:
"""Слить дайджесты: n/exp складываются, min/max расширяются."""
if not agg:
return acc
for key, value in agg.items():
if key in ("n", "exp"):
acc[key] = acc.get(key, 0) + value
elif key == "disks":
disks = acc.setdefault("disks", {})
for mount, pair in value.items():
disks[mount] = merge_pair(disks.get(mount), pair)
else:
acc[key] = merge_pair(acc.get(key), value)
return acc
def merge_pair(cur: list | None, pair: list) -> list:
return list(pair) if cur is None else [min(cur[0], pair[0]), max(cur[1], pair[1])]