UNPKG

@cocalc/project

Version:
209 lines (186 loc) 6.41 kB
/* * This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. * License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details */ /* Usage Info Server This derives usage information (cpu, mem, etc.) for a specific "path" (e.g. the corresponding jupyter process for a notebook) from the ProjectInfoServer (which collects data about everything) */ import debug from "debug"; const L = debug("project:usage-info:server"); import { EventEmitter } from "events"; import { delay } from "awaiting"; import { ProjectInfoServer, get_ProjectInfoServer } from "../project-info"; import { ProjectInfo, Process } from "../project-info/types"; import { UsageInfo } from "./types"; function is_diff(prev: UsageInfo, next: UsageInfo, key: keyof UsageInfo) { // we assume a,b >= 0, hence we leave out Math.abs operations const a = prev[key] ?? 0; const b = next[key] ?? 0; if (a === 0 && b === 0) return false; return Math.abs(b - a) / Math.min(a, b) > 0.05; } export class UsageInfoServer extends EventEmitter { private readonly dbg: Function; private running = false; private readonly testing: boolean; private readonly project_info: ProjectInfoServer; private readonly path: string; private info?: ProjectInfo; private usage?: UsageInfo; private last?: UsageInfo; constructor(path, testing = false) { super(); this.testing = testing; this.path = path; this.dbg = L; this.project_info = get_ProjectInfoServer(); this.dbg("starting"); } private async init(): Promise<void> { this.project_info.start(); this.project_info.on("info", (info) => { //this.dbg(`got info timestamp=${info.timestamp}`); this.info = info; this.update(); }); } // get the process at the given path – for now, that only works for jupyter notebooks private path_process(): Process | undefined { if (this.info?.processes == null) return; for (const p of Object.values(this.info.processes)) { const cocalc = p.cocalc; if (cocalc == null || cocalc.type != "jupyter") continue; if (cocalc.path == this.path) return p; } } // we compute the total cpu and memory usage sum for the given PID // this is a quick recursive traverse, with "stats" as the accumulator private proces_tree_stats(ppid: number, stats) { const procs = this.info?.processes; if (procs == null) return; for (const proc of Object.values(procs)) { if (proc.ppid != ppid) continue; this.proces_tree_stats(proc.pid, stats); stats.mem += proc.stat.mem.rss; stats.cpu += proc.cpu.pct; } } // cpu usage sum of all children private usage_children(pid): { cpu: number; mem: number } { const stats = { mem: 0, cpu: 0 }; this.proces_tree_stats(pid, stats); return stats; } // we silently treat non-existing information as zero usage private path_usage_info(): { cpu: number; cpu_chld: number; mem: number; mem_chld: number; } { const proc = this.path_process(); if (proc == null) { return { cpu: 0, mem: 0, cpu_chld: 0, mem_chld: 0 }; } else { // we send whole numbers. saves bandwidth and won't be displayed anyways const children = this.usage_children(proc.pid); return { cpu: Math.round(proc.cpu.pct), cpu_chld: Math.round(children.cpu), mem: Math.round(proc.stat.mem.rss), mem_chld: Math.round(children.mem), }; } } // this function takes the "info" we have (+ more maybe?) // and derives specific information for the notebook (future: also other file types) // at the given path. private update(): void { if (this.info == null) { L("was told to update, but there is no ProjectInfo"); return; } const cg = this.info.cgroup; const du = this.info.disk_usage; if (cg == null || du == null) { this.dbg("info incomplete, can't send usage data"); return; } const mem_rss = cg.mem_stat.total_rss + (du.tmp?.usage ?? 0); const mem_tot = cg.mem_stat.hierarchical_memory_limit; const usage = { time: Date.now(), ...this.path_usage_info(), mem_limit: mem_tot, cpu_limit: cg.cpu_cores_limit, mem_free: Math.max(0, mem_tot - mem_rss), }; // this.dbg("usage", usage); if (this.should_update(usage)) { this.usage = usage; this.emit("usage", this.usage); this.last = this.usage; } } // only cause to emit a change if it changed significantly (more than x%), // or if it changes close to zero (in particular, if cpu usage is low again) private should_update(usage: UsageInfo): boolean { if (this.last == null) return true; if (usage == null) return false; const keys: (keyof UsageInfo)[] = ["cpu", "mem", "cpu_chld", "mem_chld"]; for (const key of keys) { // we want everyone to know if essentially dropped to zero if ((this.last[key] ?? 0) >= 1 && (usage[key] ?? 0) < 1) return true; // … or of one of the values is significantly different if (is_diff(usage, this.last, key)) return true; } // … or if the remaining memory changed // i.e. if another process uses up a portion, there's less for the current notebook if (is_diff(usage, this.last, "mem_free")) return true; return false; } private async get_usage(): Promise<UsageInfo | undefined> { this.update(); return this.usage; } public stop(): void { this.running = false; } public async start(): Promise<void> { if (this.running) { this.dbg("UsageInfoServer already running, cannot be started twice"); } else { await this._start(); } } private async _start(): Promise<void> { this.dbg("start"); if (this.running) { throw Error("Cannot start UsageInfoServer twice"); } this.running = true; await this.init(); // emit once after startup const usage = await this.get_usage(); this.emit("usage", usage); while (this.testing) { await delay(5000); const usage = await this.get_usage(); this.emit("usage", usage); } } } // testing: $ ts-node server.ts if (require.main === module) { const uis = new UsageInfoServer("testing.ipynb", true); uis.start(); let cnt = 0; uis.on("usage", (usage) => { console.log(JSON.stringify(usage, null, 2)); cnt += 1; if (cnt >= 2) process.exit(); }); }