app/memory.py
Rama main · réplica de lectura del repositorio original.
"""Giving memory back to the operating system after the price feed runs.
Python frees the feed's pandas frames, but freeing is not returning: glibc
keeps the pages in its arenas, so RSS stays at the high-water mark and the pod
looks like it permanently needs its peak.
Two steps, in order, because neither works alone: `gc.collect()` first, since
pandas leaves reference cycles a refcount drop will not release, then
`malloc_trim(0)` to hand the free arena pages back. `malloc_trim` is a glibc
extension — a no-op on musl and macOS, which is where the tests run.
None of this changes what the app computes, only what the cgroup reports.
"""
from __future__ import annotations
import ctypes
import ctypes.util
import gc
import logging
import os
log = logging.getLogger(__name__)
# The cgroup v2 file the kubelet reads. Absent outside a container, in which
# case we fall back to the process's own RSS.
_CGROUP_CURRENT = "/sys/fs/cgroup/memory.current"
def rss_mib() -> float | None:
"""Memory currently charged to this container, in MiB.
Prefers the cgroup figure over `/proc/self/status` because that is the
number the limit is enforced against — it includes page cache and any
child processes, which VmRSS does not.
"""
try:
with open(_CGROUP_CURRENT) as handle:
return int(handle.read().strip()) / 1048576
except (OSError, ValueError):
pass
try:
with open("/proc/self/status") as handle:
for line in handle:
if line.startswith("VmRSS:"):
return int(line.split()[1]) / 1024
except OSError:
pass
return None # macOS and anything else without procfs
def _malloc_trim() -> bool:
"""Return freed arena pages to the OS. False when unavailable."""
name = ctypes.util.find_library("c")
if name is None:
return False
try:
libc = ctypes.CDLL(name)
trim = libc.malloc_trim
except (OSError, AttributeError):
# musl has no malloc_trim; macOS's libc has neither the symbol nor the
# arena behaviour that makes it necessary.
return False
trim.argtypes = [ctypes.c_size_t]
trim.restype = ctypes.c_int
trim(0)
return True
def release(context: str = "") -> dict:
"""Collect garbage, then hand the pages back. Logs what it recovered.
Safe to call from anywhere and safe to call often — it is cheap next to the
network round-trips it follows, and it never raises: a failure to trim is
reported, not propagated, because the caller's work is already done.
"""
before = rss_mib()
collected = gc.collect()
trimmed = _malloc_trim()
after = rss_mib()
freed = None if (before is None or after is None) else round(before - after, 1)
log.info(
"memory release%s: %s objects collected, malloc_trim=%s, RSS %s -> %s MiB (%s)",
f" after {context}" if context else "",
collected,
"yes" if trimmed else "unavailable",
None if before is None else round(before, 1),
None if after is None else round(after, 1),
f"freed {freed} MiB" if freed is not None else "not measurable here",
)
return {
"collected": collected,
"trimmed": trimmed,
"rss_before": before,
"rss_after": after,
"freed": freed,
}
def loaded_heavyweights() -> list[str]:
"""Which of the expensive optional imports are currently in memory.
Used by the tests that keep them lazy, and worth having as a one-line
answer to "why is this pod using 190 MiB when it is only serving pages".
"""
import sys
return sorted(
name for name in ("yfinance", "pandas", "numpy", "pdfplumber", "openpyxl")
if name in sys.modules
)
def log_snapshot(context: str) -> None:
rss = rss_mib()
if rss is not None:
log.info("memory %s: RSS %.1f MiB, loaded=%s",
context, rss, ",".join(loaded_heavyweights()) or "none")
# Kept out of the module's public surface but handy when investigating: the
# limit the cgroup will actually kill us at.
def limit_mib() -> float | None:
try:
with open(os.path.join(os.path.dirname(_CGROUP_CURRENT), "memory.max")) as h:
raw = h.read().strip()
return None if raw == "max" else int(raw) / 1048576
except (OSError, ValueError):
return None