Make Git slowdowns visible in daemon metrics (#2366)

* feat(server): expose daemon Git pressure metrics

Separate limiter queue wait from Git execution time and report subscription ownership so accumulating work is visible in the existing runtime log.

* fix(server): collect subscriptions in agent metrics

Reuse the existing agent snapshot during runtime flushes so WebSocket shutdown does not require an additional AgentManager method call.

* test(server): retry transient hub cleanup
This commit is contained in:
Mohamed Boudra
2026-07-23 21:57:08 +02:00
committed by GitHub
parent a290f74705
commit 12612f6646
13 changed files with 440 additions and 18 deletions
@@ -0,0 +1,74 @@
import { describe, expect, test } from "vitest";
import { GitCommandRuntimeMetricsWindow } from "./git-command-runtime-metrics.js";
function createMetricsWindow(concurrencyLimit = 2) {
let now = 1_000;
return {
metrics: new GitCommandRuntimeMetricsWindow(concurrencyLimit, () => now),
advance(ms: number) {
now += ms;
},
};
}
describe("GitCommandRuntimeMetricsWindow", () => {
test("separates queue wait from execution time", () => {
const { metrics, advance } = createMetricsWindow();
const command = metrics.submit("status");
advance(40);
metrics.start(command);
advance(15);
metrics.finish(command, { success: true, timedOut: false });
expect(metrics.snapshotAndReset()).toMatchObject({
submitted: 1,
started: 1,
completed: 1,
failed: 0,
timedOut: 0,
queueWaitMs: { count: 1, p50Ms: 40, p95Ms: 40, maxMs: 40 },
executionMs: { count: 1, p50Ms: 15, p95Ms: 15, maxMs: 15 },
operationsTop: [["status", 1]],
});
});
test("reports live queue pressure across window resets", () => {
const { metrics, advance } = createMetricsWindow(1);
const active = metrics.submit("fetch");
metrics.start(active);
const pending = metrics.submit("rev-parse");
metrics.observeLimiter(1, 1);
advance(25);
expect(metrics.snapshotAndReset()).toMatchObject({
concurrencyLimit: 1,
active: 1,
pending: 1,
peakActive: 1,
peakPending: 1,
oldestPendingMs: 25,
submitted: 2,
started: 1,
});
advance(10);
metrics.finish(active, { success: true, timedOut: false });
metrics.start(pending);
metrics.observeLimiter(1, 0);
advance(5);
metrics.finish(pending, { success: false, timedOut: true });
expect(metrics.snapshotAndReset()).toMatchObject({
active: 0,
pending: 0,
peakActive: 1,
peakPending: 1,
submitted: 0,
started: 1,
completed: 2,
failed: 1,
timedOut: 1,
queueWaitMs: { count: 1, p50Ms: 35, p95Ms: 35, maxMs: 35 },
});
});
});
@@ -0,0 +1,147 @@
export interface GitCommandDurationStats {
count: number;
p50Ms: number;
p95Ms: number;
maxMs: number;
}
export interface GitCommandRuntimeMetricsSnapshot {
concurrencyLimit: number;
active: number;
pending: number;
peakActive: number;
peakPending: number;
oldestPendingMs: number;
submitted: number;
started: number;
completed: number;
failed: number;
timedOut: number;
queueWaitMs: GitCommandDurationStats;
executionMs: GitCommandDurationStats;
operationsTop: Array<[string, number]>;
}
interface GitCommandRuntimeMetric {
queuedAtMs: number;
startedAtMs: number | null;
}
type Clock = () => number;
export class GitCommandRuntimeMetricsWindow {
private readonly pendingCommands = new Set<GitCommandRuntimeMetric>();
private active = 0;
private peakActive = 0;
private peakPending = 0;
private submittedCount = 0;
private startedCount = 0;
private completedCount = 0;
private failedCount = 0;
private timedOutCount = 0;
private readonly queueWaitSamples: number[] = [];
private readonly executionSamples: number[] = [];
private readonly operationCounts = new Map<string, number>();
constructor(
private readonly concurrencyLimit: number,
private readonly clock: Clock = Date.now,
) {}
submit(operation: string): GitCommandRuntimeMetric {
const metric = { queuedAtMs: this.clock(), startedAtMs: null };
this.pendingCommands.add(metric);
this.submittedCount += 1;
this.operationCounts.set(operation, (this.operationCounts.get(operation) ?? 0) + 1);
return metric;
}
observeLimiter(active: number, pending: number): void {
this.peakActive = Math.max(this.peakActive, active);
this.peakPending = Math.max(this.peakPending, pending);
}
start(metric: GitCommandRuntimeMetric): void {
if (!this.pendingCommands.delete(metric)) {
return;
}
const now = this.clock();
metric.startedAtMs = now;
this.active += 1;
this.startedCount += 1;
this.peakActive = Math.max(this.peakActive, this.active);
this.queueWaitSamples.push(Math.max(0, now - metric.queuedAtMs));
}
finish(metric: GitCommandRuntimeMetric, outcome: { success: boolean; timedOut: boolean }): void {
if (metric.startedAtMs === null) {
return;
}
const startedAtMs = metric.startedAtMs;
metric.startedAtMs = null;
this.active = Math.max(0, this.active - 1);
this.completedCount += 1;
if (!outcome.success) {
this.failedCount += 1;
}
if (outcome.timedOut) {
this.timedOutCount += 1;
}
this.executionSamples.push(Math.max(0, this.clock() - startedAtMs));
}
snapshotAndReset(
limiter = { active: this.active, pending: this.pendingCommands.size },
): GitCommandRuntimeMetricsSnapshot {
const now = this.clock();
const oldestPendingAtMs = Math.min(
...Array.from(this.pendingCommands, (metric) => metric.queuedAtMs),
);
const snapshot: GitCommandRuntimeMetricsSnapshot = {
concurrencyLimit: this.concurrencyLimit,
active: limiter.active,
pending: limiter.pending,
peakActive: this.peakActive,
peakPending: this.peakPending,
oldestPendingMs:
limiter.pending > 0 && Number.isFinite(oldestPendingAtMs)
? Math.max(0, now - oldestPendingAtMs)
: 0,
submitted: this.submittedCount,
started: this.startedCount,
completed: this.completedCount,
failed: this.failedCount,
timedOut: this.timedOutCount,
queueWaitMs: summarizeDurations(this.queueWaitSamples),
executionMs: summarizeDurations(this.executionSamples),
operationsTop: [...this.operationCounts.entries()]
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
.slice(0, 12),
};
this.peakActive = limiter.active;
this.peakPending = limiter.pending;
this.submittedCount = 0;
this.startedCount = 0;
this.completedCount = 0;
this.failedCount = 0;
this.timedOutCount = 0;
this.queueWaitSamples.length = 0;
this.executionSamples.length = 0;
this.operationCounts.clear();
return snapshot;
}
}
function summarizeDurations(samples: number[]): GitCommandDurationStats {
if (samples.length === 0) {
return { count: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 };
}
const sorted = [...samples].sort((left, right) => left - right);
return {
count: sorted.length,
p50Ms: Math.round(sorted[Math.floor(sorted.length / 2)] ?? 0),
p95Ms: Math.round(sorted[Math.ceil(sorted.length * 0.95) - 1] ?? 0),
maxMs: Math.round(sorted[sorted.length - 1] ?? 0),
};
}
+35 -11
View File
@@ -2,6 +2,10 @@ import { existsSync } from "node:fs";
import pLimit from "p-limit";
import type { Logger } from "pino";
import type { ProcessEnvRecord } from "../server/paseo-env.js";
import {
GitCommandRuntimeMetricsWindow,
type GitCommandRuntimeMetricsSnapshot,
} from "./git-command-runtime-metrics.js";
import { spawnProcess } from "./spawn.js";
const DEFAULT_TIMEOUT_MS = 30_000;
@@ -10,6 +14,7 @@ const DEFAULT_STDERR_LIMIT = 2048;
const gitConcurrency = parseInt(process.env.PASEO_GIT_CONCURRENCY ?? "8", 10) || 8;
const gitLimit = pLimit(gitConcurrency);
const gitRuntimeMetrics = new GitCommandRuntimeMetricsWindow(gitConcurrency);
export interface GitCommandOptions {
cwd: string;
@@ -81,6 +86,13 @@ export function stopGitCommandMetrics(): GitCommandMetricsSnapshot {
};
}
export function snapshotGitCommandRuntimeMetrics(): GitCommandRuntimeMetricsSnapshot {
return gitRuntimeMetrics.snapshotAndReset({
active: gitLimit.activeCount,
pending: gitLimit.pendingCount,
});
}
function beginGitCommandMetric(): GitCommandMetricsState | null {
const state = gitCommandMetricsState;
if (!state) {
@@ -123,9 +135,11 @@ export function runGitCommand(
args: string[],
options: GitCommandOptions,
): Promise<GitCommandResult> {
return gitLimit(
const runtimeMetric = gitRuntimeMetrics.submit(getGitOperation(args));
const promise = gitLimit(
() =>
new Promise<GitCommandResult>((resolve, reject) => {
gitRuntimeMetrics.start(runtimeMetric);
const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
const acceptExitCodes = options.acceptExitCodes ?? [0];
@@ -175,24 +189,28 @@ export function runGitCommand(
callback();
};
const finishMetricOnce = (metric: GitCommandMetric) => {
const finishMetricOnce = (metric: GitCommandMetric, timedOut = false) => {
if (metricFinished) return;
metricFinished = true;
finishGitCommandMetric(metricsState, metric);
gitRuntimeMetrics.finish(runtimeMetric, { success: metric.success, timedOut });
};
const timer = setTimeout(() => {
const error = new Error(`Git command timed out after ${timeout}ms: ${command}`);
child.kill("SIGKILL");
finishMetricOnce({
args,
cwd: options.cwd,
startedAtMs: startedAt,
durationMs: Date.now() - startedAt,
exitCode: null,
signal: "SIGKILL",
success: false,
});
finishMetricOnce(
{
args,
cwd: options.cwd,
startedAtMs: startedAt,
durationMs: Date.now() - startedAt,
exitCode: null,
signal: "SIGKILL",
success: false,
},
true,
);
settle(() => reject(error));
}, timeout);
@@ -318,8 +336,14 @@ export function runGitCommand(
});
}),
);
gitRuntimeMetrics.observeLimiter(gitLimit.activeCount, gitLimit.pendingCount);
return promise;
}
function formatGitCommand(args: string[]): string {
return ["git", ...args].join(" ");
}
function getGitOperation(args: string[]): string {
return args[0] === "-c" ? (args[2] ?? "unknown") : (args[0] ?? "unknown");
}