mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 01:32:40 +00:00
* fix(server): keep Git pressure from stalling daemon Git process starts were refilled synchronously, so slow process spawning could monopolize the main loop long enough to trip connection liveness checks. Admit one process per event-loop turn and expose refresh/fetch provenance in runtime queue metrics. * perf(server): avoid delaying isolated Git commands Start the first command immediately when the scheduler is idle. Backlog admission and completion-driven refills still yield between spawns, preserving daemon liveness without adding a turn to normal sequential Git traffic. * fix(server): keep Git admission outside fake clocks Subprocess admission is event-loop ordering rather than simulated wall-clock behavior. Use the native Node immediate so service tests can fake debounce timers without deadlocking queued real Git processes. * refactor(server): bind Git command provenance explicitly * test(server): expect bound Git fetch runner
145 lines
4.2 KiB
TypeScript
145 lines
4.2 KiB
TypeScript
import { setImmediate as scheduleImmediate } from "node:timers";
|
|
import pThrottle from "p-throttle";
|
|
|
|
export interface GitProcessPolicy {
|
|
maxProcessesPerSecond: number;
|
|
maxProcessConcurrency: number;
|
|
}
|
|
|
|
export const DEFAULT_GIT_PROCESS_POLICY: GitProcessPolicy = {
|
|
maxProcessesPerSecond: 64,
|
|
maxProcessConcurrency: 8,
|
|
};
|
|
|
|
export interface ScheduledGitProcess<T> {
|
|
result: Promise<T>;
|
|
exited: Promise<void>;
|
|
}
|
|
|
|
export type GitProcessPriority = "normal" | "high";
|
|
|
|
function parsePositiveInteger(value: string | undefined): number | undefined {
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
const parsed = Number(value);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
}
|
|
|
|
export function resolveGitProcessPolicy(input: {
|
|
env: NodeJS.ProcessEnv;
|
|
persisted?: Partial<GitProcessPolicy>;
|
|
}): GitProcessPolicy {
|
|
return {
|
|
maxProcessesPerSecond:
|
|
parsePositiveInteger(input.env.PASEO_GIT_MAX_PROCESSES_PER_SECOND) ??
|
|
input.persisted?.maxProcessesPerSecond ??
|
|
DEFAULT_GIT_PROCESS_POLICY.maxProcessesPerSecond,
|
|
maxProcessConcurrency:
|
|
parsePositiveInteger(input.env.PASEO_GIT_MAX_PROCESS_CONCURRENCY) ??
|
|
// COMPAT(gitConcurrencyEnv): renamed in v0.2.6; remove after 2027-02-02.
|
|
parsePositiveInteger(input.env.PASEO_GIT_CONCURRENCY) ??
|
|
input.persisted?.maxProcessConcurrency ??
|
|
DEFAULT_GIT_PROCESS_POLICY.maxProcessConcurrency,
|
|
};
|
|
}
|
|
|
|
export class GitProcessScheduler {
|
|
private readonly startThrottled;
|
|
private readonly highPriorityQueue: Array<() => void> = [];
|
|
private readonly normalPriorityQueue: Array<() => void> = [];
|
|
private admitted = 0;
|
|
private running = 0;
|
|
private waiting = 0;
|
|
private drainScheduled = false;
|
|
|
|
constructor(readonly policy: GitProcessPolicy) {
|
|
const throttle = pThrottle({
|
|
limit: policy.maxProcessesPerSecond,
|
|
interval: 1_000,
|
|
strict: true,
|
|
});
|
|
this.startThrottled = throttle((start: () => Promise<void>) => start());
|
|
}
|
|
|
|
get activeCount(): number {
|
|
return this.running;
|
|
}
|
|
|
|
get pendingCount(): number {
|
|
return this.waiting;
|
|
}
|
|
|
|
run<T>(
|
|
start: () => ScheduledGitProcess<T>,
|
|
options?: { priority?: GitProcessPriority },
|
|
): Promise<T> {
|
|
this.waiting += 1;
|
|
return new Promise<T>((resolve, reject) => {
|
|
const admit = () => {
|
|
void this.startThrottled(() => this.execute(start, resolve, reject)).catch((error) => {
|
|
this.waiting = Math.max(0, this.waiting - 1);
|
|
this.admitted = Math.max(0, this.admitted - 1);
|
|
reject(error);
|
|
this.scheduleDrain();
|
|
});
|
|
};
|
|
const queue =
|
|
options?.priority === "high" ? this.highPriorityQueue : this.normalPriorityQueue;
|
|
queue.push(admit);
|
|
if (this.admitted === 0 && !this.drainScheduled) {
|
|
this.drainOne();
|
|
} else {
|
|
this.scheduleDrain();
|
|
}
|
|
});
|
|
}
|
|
|
|
private scheduleDrain(): void {
|
|
if (this.drainScheduled || this.admitted >= this.policy.maxProcessConcurrency) {
|
|
return;
|
|
}
|
|
if (this.highPriorityQueue.length === 0 && this.normalPriorityQueue.length === 0) {
|
|
return;
|
|
}
|
|
this.drainScheduled = true;
|
|
scheduleImmediate(() => this.drainOne());
|
|
}
|
|
|
|
private drainOne(): void {
|
|
this.drainScheduled = false;
|
|
if (this.admitted >= this.policy.maxProcessConcurrency) {
|
|
return;
|
|
}
|
|
const admit = this.highPriorityQueue.shift() ?? this.normalPriorityQueue.shift();
|
|
if (!admit) {
|
|
return;
|
|
}
|
|
this.admitted += 1;
|
|
admit();
|
|
// Spawning can be synchronous and expensive on a throttled host. Let timers,
|
|
// sockets, and child exits run before admitting another Git process.
|
|
this.scheduleDrain();
|
|
}
|
|
|
|
private async execute<T>(
|
|
start: () => ScheduledGitProcess<T>,
|
|
resolve: (value: T | PromiseLike<T>) => void,
|
|
reject: (reason?: unknown) => void,
|
|
): Promise<void> {
|
|
this.waiting = Math.max(0, this.waiting - 1);
|
|
this.running += 1;
|
|
try {
|
|
const process = start();
|
|
void process.result.then(resolve, reject);
|
|
await process.exited;
|
|
} catch (error) {
|
|
reject(error);
|
|
} finally {
|
|
this.running = Math.max(0, this.running - 1);
|
|
this.admitted = Math.max(0, this.admitted - 1);
|
|
this.scheduleDrain();
|
|
}
|
|
}
|
|
}
|