Skip to content

Commit 563b97b

Browse files
authored
fix(discord): ignore Jaeger file cache in memory alerts (#94)
1 parent b578fce commit 563b97b

2 files changed

Lines changed: 162 additions & 17 deletions

File tree

apps/discord-bot/src/features/Alerts.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
classifySessionLastError,
66
formatAlertCause,
77
isExpectedSessionLastError,
8+
memoryPolicyForProcess,
9+
parseProcMemoryStatus,
810
selectSessionErrorsForAlert,
911
sessionErrorAlertKey,
1012
trackSustainedHotProcesses,
@@ -20,6 +22,8 @@ const SUSTAINED_TICKS = 5;
2022
const proc = (over: Partial<ProcInfo> & { pid: number }): ProcInfo => ({
2123
pid: over.pid,
2224
rssMb: over.rssMb ?? 100,
25+
rssAnonMb: over.rssAnonMb ?? 50,
26+
rssFileMb: over.rssFileMb ?? 50,
2327
cpuSeconds: over.cpuSeconds ?? 0,
2428
cmd: over.cmd ?? `/bin/proc-${over.pid}`,
2529
label: over.label ?? `proc-${over.pid}`,
@@ -42,6 +46,7 @@ function run(
4246
nowMs: startMs + index * TICK_MS,
4347
cpuPercentThreshold: CPU_THRESHOLD,
4448
rssMbThreshold: RSS_THRESHOLD,
49+
memoryPolicyFor: (process) => memoryPolicyForProcess(process, RSS_THRESHOLD),
4550
sustainedTicks: SUSTAINED_TICKS,
4651
});
4752
state = result.next;
@@ -126,6 +131,20 @@ describe("session last_error alert classification", () => {
126131
});
127132

128133
describe("trackSustainedHotProcesses", () => {
134+
it("parses total, anonymous, and file-backed RSS from proc status", () => {
135+
expect(
136+
parseProcMemoryStatus(`
137+
VmRSS: 1638400 kB
138+
RssAnon: 921600 kB
139+
RssFile: 716800 kB
140+
`),
141+
).toEqual({
142+
rssMb: 1_600,
143+
rssAnonMb: 900,
144+
rssFileMb: 700,
145+
});
146+
});
147+
129148
it("does not alert on a long-lived but idle process", () => {
130149
// The reported bug: a process with lots of cumulative CPU time that now barely
131150
// moves (a few seconds every tick) must never alert. +2s of CPU per 60s tick
@@ -173,9 +192,63 @@ describe("trackSustainedHotProcesses", () => {
173192
const hot = run(ticks).hot;
174193
expect(hot).toHaveLength(1);
175194
expect(hot[0]!.rssMb).toBe(900);
195+
expect(hot[0]!.memoryKind).toBe("rss");
176196
expect(hot[0]!.cpuPercent).toBe(0);
177197
});
178198

199+
it("ignores Jaeger file cache below the anonymous-memory threshold", () => {
200+
const ticks = Array.from({ length: SUSTAINED_TICKS + 1 }, () => [
201+
proc({
202+
pid: 950,
203+
rssMb: 3_000,
204+
rssAnonMb: 900,
205+
rssFileMb: 2_100,
206+
cmd: "/cmd/jaeger/jaeger-linux --config=/etc/jaeger/config.yaml",
207+
}),
208+
]);
209+
210+
expect(run(ticks).hot).toEqual([]);
211+
});
212+
213+
it("alerts once Jaeger sustains 2 GiB of anonymous memory", () => {
214+
const ticks = Array.from({ length: SUSTAINED_TICKS + 1 }, () => [
215+
proc({
216+
pid: 950,
217+
rssMb: 3_000,
218+
rssAnonMb: 2 * 1024,
219+
rssFileMb: 952,
220+
cmd: "/cmd/jaeger/jaeger-linux --config=/etc/jaeger/config.yaml",
221+
}),
222+
]);
223+
224+
expect(run(ticks).hot).toEqual([
225+
expect.objectContaining({
226+
pid: 950,
227+
memoryKind: "anonymous",
228+
memoryValueMb: 2 * 1024,
229+
memoryThresholdMb: 2 * 1024,
230+
}),
231+
]);
232+
});
233+
234+
it("falls back to total RSS when the kernel omits the Jaeger RSS breakdown", () => {
235+
expect(
236+
memoryPolicyForProcess(
237+
proc({
238+
pid: 950,
239+
rssMb: 2 * 1024,
240+
rssAnonMb: 0,
241+
rssFileMb: 0,
242+
cmd: "/cmd/jaeger/jaeger-linux",
243+
}),
244+
),
245+
).toEqual({
246+
valueMb: 2 * 1024,
247+
thresholdMb: 2 * 1024,
248+
kind: "rss",
249+
});
250+
});
251+
179252
it("treats a reused pid as a new process and resets its streak", () => {
180253
const busy = Array.from({ length: 4 }, (_unused, i) => [
181254
proc({ pid: 900, cpuSeconds: i * 60 }),

apps/discord-bot/src/features/Alerts.ts

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,11 @@ const DISK_FREE_MIN_GB = 2;
3838
const SENTRY_RSS_ALERT_MB = 512;
3939
const SENTRY_COUNT_ALERT = 2;
4040
const STUCK_RSS_ALERT_MB = 768;
41+
const JAEGER_ANON_ALERT_MB = 2 * 1024;
4142
/**
42-
* A process is "hot" when it holds ≥STUCK_RSS_ALERT_MB of RSS or averages
43-
* SUSTAINED_CPU_PERCENT of a core, and it only alerts once it has stayed hot
44-
* for SUSTAINED_TICKS consecutive ticks.
43+
* A process is "hot" when it exceeds its memory policy or averages at least
44+
* SUSTAINED_CPU_PERCENT of a core, and it only alerts once it has stayed hot for
45+
* SUSTAINED_TICKS consecutive ticks.
4546
*
4647
* This measures a *rate* (Δcpu / Δwall between ticks), not cumulative CPU time:
4748
* a long-lived-but-idle process (e.g. one that gathered 200s of CPU over hours
@@ -77,11 +78,38 @@ const RUNAWAY_PATTERNS: ReadonlyArray<{
7778
export interface ProcInfo {
7879
readonly pid: number;
7980
readonly rssMb: number;
81+
readonly rssAnonMb: number;
82+
readonly rssFileMb: number;
8083
readonly cpuSeconds: number;
8184
readonly cmd: string;
8285
readonly label: string;
8386
}
8487

88+
export interface ProcMemoryPolicy {
89+
readonly valueMb: number;
90+
readonly thresholdMb: number;
91+
readonly kind: "anonymous" | "rss";
92+
}
93+
94+
export function memoryPolicyForProcess(
95+
proc: ProcInfo,
96+
defaultRssMbThreshold = STUCK_RSS_ALERT_MB,
97+
): ProcMemoryPolicy {
98+
if (/(?:^|[/\s])jaeger(?:$|[/\s-])/i.test(proc.cmd)) {
99+
const hasRssBreakdown = proc.rssAnonMb > 0 || proc.rssFileMb > 0;
100+
return {
101+
valueMb: hasRssBreakdown ? proc.rssAnonMb : proc.rssMb,
102+
thresholdMb: JAEGER_ANON_ALERT_MB,
103+
kind: hasRssBreakdown ? "anonymous" : "rss",
104+
};
105+
}
106+
return {
107+
valueMb: proc.rssMb,
108+
thresholdMb: defaultRssMbThreshold,
109+
kind: "rss",
110+
};
111+
}
112+
85113
/** Per-process tracker state carried between ticks to derive a CPU rate. */
86114
export interface ProcSustainState {
87115
readonly cpuSeconds: number;
@@ -96,6 +124,11 @@ export interface ProcSustainState {
96124
export interface SustainedHotProcess {
97125
readonly pid: number;
98126
readonly rssMb: number;
127+
readonly rssAnonMb: number;
128+
readonly rssFileMb: number;
129+
readonly memoryValueMb: number;
130+
readonly memoryThresholdMb: number;
131+
readonly memoryKind: ProcMemoryPolicy["kind"];
99132
/** Average CPU over the last tick gap, as percent of a single core. */
100133
readonly cpuPercent: number;
101134
/** How long it has been continuously hot. */
@@ -116,6 +149,7 @@ export function trackSustainedHotProcesses(input: {
116149
readonly nowMs: number;
117150
readonly cpuPercentThreshold: number;
118151
readonly rssMbThreshold: number;
152+
readonly memoryPolicyFor?: (proc: ProcInfo) => ProcMemoryPolicy;
119153
readonly sustainedTicks: number;
120154
}): {
121155
readonly next: Map<number, ProcSustainState>;
@@ -136,9 +170,14 @@ export function trackSustainedHotProcesses(input: {
136170
? (Math.max(0, proc.cpuSeconds - previous.cpuSeconds) / (elapsedMs / 1_000)) * 100
137171
: null;
138172

173+
const memoryPolicy = input.memoryPolicyFor?.(proc) ?? {
174+
valueMb: proc.rssMb,
175+
thresholdMb: input.rssMbThreshold,
176+
kind: "rss",
177+
};
139178
const isHot =
140179
(cpuPercent !== null && cpuPercent >= input.cpuPercentThreshold) ||
141-
proc.rssMb >= input.rssMbThreshold;
180+
memoryPolicy.valueMb >= memoryPolicy.thresholdMb;
142181
const hotTicks = isHot ? (previous?.hotTicks ?? 0) + 1 : 0;
143182
const hotSinceMs = isHot
144183
? previous?.hotTicks
@@ -157,14 +196,19 @@ export function trackSustainedHotProcesses(input: {
157196
hot.push({
158197
pid: proc.pid,
159198
rssMb: proc.rssMb,
199+
rssAnonMb: proc.rssAnonMb,
200+
rssFileMb: proc.rssFileMb,
201+
memoryValueMb: memoryPolicy.valueMb,
202+
memoryThresholdMb: memoryPolicy.thresholdMb,
203+
memoryKind: memoryPolicy.kind,
160204
cpuPercent: cpuPercent ?? 0,
161205
sustainedMs: input.nowMs - hotSinceMs,
162206
label: proc.label,
163207
});
164208
}
165209
}
166210

167-
hot.sort((a, b) => b.cpuPercent - a.cpuPercent || b.rssMb - a.rssMb);
211+
hot.sort((a, b) => b.cpuPercent - a.cpuPercent || b.memoryValueMb - a.memoryValueMb);
168212
return { next, hot: hot.slice(0, 8) };
169213
}
170214

@@ -354,13 +398,32 @@ function readDisk(path: string): DiskInfo | null {
354398
}
355399
}
356400

357-
function readRssMb(pid: number): number {
401+
export function parseProcMemoryStatus(status: string): {
402+
readonly rssMb: number;
403+
readonly rssAnonMb: number;
404+
readonly rssFileMb: number;
405+
} {
406+
const readMb = (field: string) => {
407+
const match = new RegExp(`^${field}:\\s+(\\d+)\\s+kB`, "m").exec(status);
408+
return match ? Number(match[1]) / 1024 : 0;
409+
};
410+
return {
411+
rssMb: readMb("VmRSS"),
412+
rssAnonMb: readMb("RssAnon"),
413+
rssFileMb: readMb("RssFile"),
414+
};
415+
}
416+
417+
function readProcMemory(pid: number): {
418+
readonly rssMb: number;
419+
readonly rssAnonMb: number;
420+
readonly rssFileMb: number;
421+
} {
358422
try {
359423
const status = NodeFS.readFileSync(`/proc/${pid}/status`, "utf8");
360-
const match = /^VmRSS:\s+(\d+)\s+kB/m.exec(status);
361-
return match ? Number(match[1]) / 1024 : 0;
424+
return parseProcMemoryStatus(status);
362425
} catch {
363-
return 0;
426+
return { rssMb: 0, rssAnonMb: 0, rssFileMb: 0 };
364427
}
365428
}
366429

@@ -414,9 +477,10 @@ function listProcesses(): ReadonlyArray<ProcInfo> {
414477
if (pid === process.pid) continue;
415478
const cmd = readCmdline(pid);
416479
if (cmd === "") continue;
480+
const memory = readProcMemory(pid);
417481
out.push({
418482
pid,
419-
rssMb: readRssMb(pid),
483+
...memory,
420484
cpuSeconds: readCpuSeconds(pid),
421485
cmd,
422486
label: shortCmd(cmd),
@@ -456,6 +520,7 @@ function listFatProcesses(
456520
nowMs,
457521
cpuPercentThreshold: SUSTAINED_CPU_PERCENT,
458522
rssMbThreshold: STUCK_RSS_ALERT_MB,
523+
memoryPolicyFor: (proc) => memoryPolicyForProcess(proc, STUCK_RSS_ALERT_MB),
459524
sustainedTicks: SUSTAINED_TICKS,
460525
});
461526
sustainState = next;
@@ -803,13 +868,20 @@ export const runAlertWatchdog = (botConfig: DiscordBotConfig) =>
803868
yield* postAlert(
804869
"stuck-proc",
805870
[
806-
"**Sustained high RSS / CPU process(es)**",
807-
...fatNonRunaway.map(
808-
(p) =>
809-
`• pid=${p.pid} rss=${p.rssMb.toFixed(0)}MiB cpu≈${p.cpuPercent.toFixed(0)}% ` +
810-
`for ${Math.round(p.sustainedMs / 60_000)}m ${p.label}`,
811-
),
812-
`_Sustained ≥${SUSTAINED_TICKS} ticks with RSS≥${STUCK_RSS_ALERT_MB}MiB or CPU≥${SUSTAINED_CPU_PERCENT}% of a core (not auto-killed)._`,
871+
"**Sustained high memory / CPU process(es)**",
872+
...fatNonRunaway.map((p) => {
873+
const memory =
874+
p.memoryKind === "anonymous"
875+
? `anon=${p.rssAnonMb.toFixed(0)}MiB file-cache=${p.rssFileMb.toFixed(0)}MiB rss=${p.rssMb.toFixed(0)}MiB`
876+
: `rss=${p.rssMb.toFixed(0)}MiB`;
877+
return (
878+
`• pid=${p.pid} ${memory} ` +
879+
`(memory alert ${p.memoryKind}${p.memoryThresholdMb.toFixed(0)}MiB) ` +
880+
`cpu≈${p.cpuPercent.toFixed(0)}% ` +
881+
`for ${Math.round(p.sustainedMs / 60_000)}m ${p.label}`
882+
);
883+
}),
884+
`_Sustained ≥${SUSTAINED_TICKS} ticks with process-specific high memory or CPU≥${SUSTAINED_CPU_PERCENT}% of a core (not auto-killed)._`,
813885
].join("\n"),
814886
);
815887
}

0 commit comments

Comments
 (0)