#!/usr/bin/env python3 """Continuously record MAA Online memory/swap behaviour into a log. Run once per minute by maa-online-memlog.timer. Each run appends one row to /logs/memlog.csv (auto-rotated at 5 MB, one generation kept) and prints a compact line to the journal. Why this is instrumented the way it is: a streaming session makes the API server's resident memory climb by tens of MB per minute and never give it back. The interesting question each time is *where* it lands, so this sampler records * the service cgroup's own breakdown (anon / file / kernel / shmem), * the process RSS split into anonymous ([heap], [anon...], unnamed), named file-backed mappings, and everything else, * the largest named mappings, so an unexpected consumer names itself. Anonymous memory that reaches swap is the failure mode we care about, hence the explicit cg_swap_* and system swap columns. """ from __future__ import annotations import os import re import subprocess import sys import time import urllib.error import urllib.request from pathlib import Path ROOT = Path(os.environ.get("MAA_ONLINE_ROOT") or Path(__file__).resolve().parent.parent) LOG_DIR = Path(os.environ.get("MAA_ONLINE_LOG_DIR", ROOT / "logs")) LOG = Path(os.environ.get("MAA_ONLINE_MEMLOG", LOG_DIR / "memlog.csv")) MAX_BYTES = int(os.environ.get("MAA_ONLINE_MEMLOG_MAX_BYTES", 5 * 1024 * 1024)) CG = Path("/sys/fs/cgroup/system.slice/maa-online-server.service") API = os.environ.get("MAA_ONLINE_API", "http://127.0.0.1:22888") FIELDS = [ "timestamp", "cloud_state", "pid", "proc_uptime_s", "cg_current_mb", "cg_peak_mb", "cg_swap_mb", "cg_swap_peak_mb", "rss_mb", "anon_regions", "big_anon_regions", "anon_total_mb", "sys_mem_avail_mb", "sys_swap_used_mb", "sys_swap_free_mb", "cg_anon_mb", "cg_file_mb", "cg_kernel_mb", "cg_shmem_mb", "heap_rss_mb", "anon_rss_mb", "file_rss_mb", "other_rss_mb", "top_named", ] MIB = 1048576 ANON_NAME = re.compile(r"^\[anon") def read_text(path: Path, default: str = "") -> str: try: return path.read_text(encoding="utf-8").strip() except OSError: return default def cg_value(name: str) -> int: try: return int(read_text(CG / name, "0")) except ValueError: return 0 def cg_stat(name: str) -> int: for line in read_text(CG / "memory.stat").splitlines(): key, _, value = line.partition(" ") if key == name: try: return int(value) except ValueError: return 0 return 0 def mb(value: int) -> float: return round(value / MIB, 1) def main_pid() -> int: try: out = subprocess.run( ["systemctl", "show", "-p", "MainPID", "--value", "maa-online-server.service"], capture_output=True, text=True, timeout=10, check=False, ).stdout.strip() return int(out or 0) except (OSError, ValueError, subprocess.SubprocessError): return 0 def proc_uptime(pid: int) -> int: stat = read_text(Path(f"/proc/{pid}/stat")) if not stat: return 0 try: start_ticks = int(stat.rsplit(")", 1)[1].split()[19]) except (IndexError, ValueError): return 0 try: with open("/proc/uptime") as handle: uptime = float(handle.read().split()[0]) except (OSError, ValueError): return 0 return int(uptime - start_ticks / os.sysconf("SC_CLK_TCK")) def proc_memory(pid: int) -> dict: """Split the process RSS by mapping kind, and name the biggest file mappings.""" rss = anon_rss = file_rss = heap_rss = 0 anon_regions = big_anon_regions = 0 anon_total = 0 named: dict[str, float] = {} name = "" is_anon = is_file = False try: with open(f"/proc/{pid}/smaps") as handle: for line in handle: if line[0] in "0123456789abcdef" and "-" in line.split(" ", 1)[0]: parts = line.split() start, end = parts[0].split("-") size = (int(end, 16) - int(start, 16)) // 1024 name = parts[5] if len(parts) > 5 else "" is_anon = name == "" or name == "[heap]" or bool(ANON_NAME.match(name)) is_file = name.startswith("/") if is_anon: anon_regions += 1 anon_total += size if size >= 32 * 1024: big_anon_regions += 1 elif line.startswith("Rss:"): value = int(line.split()[1]) rss += value if is_anon: anon_rss += value if name == "[heap]": heap_rss += value elif is_file and name: file_rss += value named[name] = named.get(name, 0.0) + value / 1024 except OSError: pass top = ", ".join( f"{Path(n).name}={v:.0f}MB" for n, v in sorted(named.items(), key=lambda kv: -kv[1])[:3] if v > 0 ) return { "rss_mb": round(rss / 1024, 1), "anon_regions": anon_regions, "big_anon_regions": big_anon_regions, "anon_total_mb": round(anon_total / 1024, 1), "heap_rss_mb": round(heap_rss / 1024, 1), "anon_rss_mb": round(anon_rss / 1024, 1), "file_rss_mb": round(file_rss / 1024, 1), "other_rss_mb": round((rss - anon_rss - file_rss) / 1024, 1), "top_named": top.replace(",", ";") or "-", } def system_memory() -> tuple[float, float, float]: values: dict[str, int] = {} try: with open("/proc/meminfo") as handle: for line in handle: key, _, rest = line.partition(":") values[key] = int(rest.split()[0]) except (OSError, ValueError, IndexError): return 0.0, 0.0, 0.0 return ( mb(values.get("MemAvailable", 0) * 1024), mb((values.get("SwapTotal", 0) - values.get("SwapFree", 0)) * 1024), mb(values.get("SwapFree", 0) * 1024), ) def cloud_state() -> str: try: with urllib.request.urlopen(f"{API}/info", timeout=5) as response: body = response.read().decode("utf-8", "replace") except (urllib.error.URLError, OSError, ValueError): return "unreachable" match = re.search(r'"status"\s*:\s*"([^"]+)"', body) return match.group(1) if match else "unknown" def rotate_if_needed() -> None: try: if LOG.exists() and LOG.stat().st_size > MAX_BYTES: backup = LOG.with_name(LOG.name + ".1") backup.unlink(missing_ok=True) LOG.rename(backup) except OSError: pass def main() -> int: LOG_DIR.mkdir(parents=True, exist_ok=True) rotate_if_needed() pid = main_pid() state = cloud_state() if pid: mem = proc_memory(pid) uptime = proc_uptime(pid) else: mem = { "rss_mb": 0, "anon_regions": 0, "big_anon_regions": 0, "anon_total_mb": 0, "heap_rss_mb": 0, "anon_rss_mb": 0, "file_rss_mb": 0, "other_rss_mb": 0, "top_named": "-", } uptime = 0 mem_avail, swap_used, swap_free = system_memory() row = [ time.strftime("%Y-%m-%dT%H:%M:%S%z"), state, pid, uptime, mb(cg_value("memory.current")), mb(cg_value("memory.peak")), mb(cg_value("memory.swap.current")), mb(cg_value("memory.swap.peak")), mem["rss_mb"], mem["anon_regions"], mem["big_anon_regions"], mem["anon_total_mb"], mem_avail, swap_used, swap_free, mb(cg_stat("anon")), mb(cg_stat("file")), mb(cg_stat("kernel")), mb(cg_stat("shmem")), mem["heap_rss_mb"], mem["anon_rss_mb"], mem["file_rss_mb"], mem["other_rss_mb"], mem["top_named"], ] fresh = not LOG.exists() or LOG.stat().st_size == 0 with LOG.open("a", encoding="utf-8") as handle: if fresh: handle.write(",".join(FIELDS) + "\n") handle.write(",".join(str(value) for value in row) + "\n") print( f"{row[0]} state={state} pid={pid} cg={row[4]}MB peak={row[5]}MB cg_swap={row[6]}MB " f"rss={mem['rss_mb']}MB (heap={mem['heap_rss_mb']} anon={mem['anon_rss_mb']} " f"file={mem['file_rss_mb']} other={mem['other_rss_mb']}) " f"cg_anon={row[15]}MB cg_file={row[16]}MB regions={mem['anon_regions']} " f"big={mem['big_anon_regions']} top=[{mem['top_named']}]" ) return 0 if __name__ == "__main__": sys.exit(main())