perf: frontend request cache, poll pause, and code-split shell (#1052)

Host/status caching, shell code-split, SSH pool waits, host-metrics concurrency, background-tab idle, per-host status subscriptions, homepage poll quieting, and virtualized host sidebar + file manager lists.
This commit is contained in:
ZacharyZcR
2026-07-14 13:58:28 +08:00
committed by GitHub
parent 6e66a5a4ef
commit 867058bddd
53 changed files with 2663 additions and 831 deletions
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import { ConcurrentLimiter, HostPollCache } from "./host-metrics-state.js";
describe("ConcurrentLimiter", () => {
it("never exceeds max concurrent runners", async () => {
const limiter = new ConcurrentLimiter(2);
let peak = 0;
let current = 0;
const job = async () => {
current += 1;
peak = Math.max(peak, current);
await new Promise((r) => setTimeout(r, 30));
current -= 1;
};
await Promise.all([
limiter.run(job),
limiter.run(job),
limiter.run(job),
limiter.run(job),
]);
expect(peak).toBeLessThanOrEqual(2);
expect(limiter.activeCount).toBe(0);
expect(limiter.pendingCount).toBe(0);
});
it("runs waiters in FIFO order after a slot frees", async () => {
const limiter = new ConcurrentLimiter(1);
const order: number[] = [];
const first = limiter.run(async () => {
order.push(1);
await new Promise((r) => setTimeout(r, 20));
});
const second = limiter.run(async () => {
order.push(2);
});
const third = limiter.run(async () => {
order.push(3);
});
await Promise.all([first, second, third]);
expect(order).toEqual([1, 2, 3]);
});
it("rejects invalid maxConcurrent", () => {
expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/);
});
});
describe("HostPollCache", () => {
it("returns cached host within TTL for the same user", () => {
const cache = new HostPollCache<{ id: number; name: string }>(60_000);
cache.set(1, "user-a", { id: 1, name: "alpha" });
expect(cache.get(1, "user-a")).toEqual({ id: 1, name: "alpha" });
expect(cache.get(1, "user-b")).toBeNull();
});
it("expires entries after TTL", () => {
vi.useFakeTimers();
const cache = new HostPollCache<{ id: number }>(1_000);
cache.set(7, "u", { id: 7 });
expect(cache.get(7, "u")).toEqual({ id: 7 });
vi.advanceTimersByTime(1_001);
expect(cache.get(7, "u")).toBeNull();
vi.useRealTimers();
});
it("invalidate drops a host or the whole cache", () => {
const cache = new HostPollCache<{ id: number }>(60_000);
cache.set(1, "u", { id: 1 });
cache.set(2, "u", { id: 2 });
cache.invalidate(1);
expect(cache.get(1, "u")).toBeNull();
expect(cache.get(2, "u")).toEqual({ id: 2 });
cache.invalidate();
expect(cache.get(2, "u")).toBeNull();
});
});
+82
View File
@@ -250,6 +250,88 @@ class PollingBackoff {
}
}
/**
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
* Used to stop host-metrics status/metrics polls from stampeding under load.
*/
export class ConcurrentLimiter {
private active = 0;
private readonly waiters: Array<() => void> = [];
constructor(private readonly maxConcurrent: number) {
if (maxConcurrent < 1) {
throw new Error("maxConcurrent must be >= 1");
}
}
get activeCount(): number {
return this.active;
}
get pendingCount(): number {
return this.waiters.length;
}
async run<T>(fn: () => Promise<T>): Promise<T> {
if (this.active >= this.maxConcurrent) {
await new Promise<void>((resolve) => {
this.waiters.push(resolve);
});
}
this.active += 1;
try {
return await fn();
} finally {
this.active -= 1;
const next = this.waiters.shift();
if (next) next();
}
}
}
/** Short-lived host snapshots for polling — avoids decrypting host rows every tick. */
export class HostPollCache<THost extends { id: number } = { id: number }> {
private cache = new Map<
number,
{ host: THost; userId: string; expiresAt: number }
>();
constructor(private readonly ttlMs = 30_000) {}
get(hostId: number, userId: string): THost | null {
const entry = this.cache.get(hostId);
if (!entry) return null;
if (entry.userId !== userId || Date.now() >= entry.expiresAt) {
this.cache.delete(hostId);
return null;
}
return entry.host;
}
set(hostId: number, userId: string, host: THost): void {
this.cache.set(hostId, {
host,
userId,
expiresAt: Date.now() + this.ttlMs,
});
}
invalidate(hostId?: number): void {
if (hostId === undefined) {
this.cache.clear();
return;
}
this.cache.delete(hostId);
}
}
/** TCP status checks are cheap relative to SSH metrics collection. */
export const statusPollLimiter = new ConcurrentLimiter(20);
/** SSH metrics execs are expensive; keep concurrency tight. */
export const metricsPollLimiter = new ConcurrentLimiter(5);
export const hostPollCache = new HostPollCache(30_000);
export const requestQueue = new RequestQueue();
export const metricsCache = new MetricsCache();
export const authFailureTracker = new AuthFailureTracker();
+115 -14
View File
@@ -62,9 +62,12 @@ import {
} from "./host-metrics-sessions.js";
import {
authFailureTracker,
hostPollCache,
metricsCache,
metricsPollLimiter,
pollingBackoff,
requestQueue,
statusPollLimiter,
} from "./host-metrics-state.js";
const authManager = AuthManager.getInstance();
@@ -169,6 +172,9 @@ class PollingManager {
private activeViewers = new Map<number, Set<string>>();
private viewerDetails = new Map<string, MetricsViewer>();
private viewerCleanupInterval: NodeJS.Timeout;
/** Skip stacking another status/metrics poll while one is already running. */
private statusInFlight = new Set<number>();
private metricsInFlight = new Set<number>();
constructor() {
this.viewerCleanupInterval = setInterval(() => {
@@ -176,6 +182,81 @@ class PollingManager {
}, 60000);
}
/** Spread timers so N hosts do not fire on the same wall-clock second. */
private intervalWithJitter(intervalMs: number, hostId: number): number {
const spread = Math.min(intervalMs * 0.2, 15_000);
const jitter = (hostId * 1103515245) % Math.max(1, Math.floor(spread));
return intervalMs + jitter;
}
private async resolveHostForPoll(
host: SSHHostWithCredentials,
userId: string,
): Promise<SSHHostWithCredentials | null> {
const cached = hostPollCache.get(
host.id,
userId,
) as SSHHostWithCredentials | null;
if (cached) {
return cached;
}
const refreshed = await fetchHostById(host.id, userId);
if (!refreshed) {
return null;
}
hostPollCache.set(host.id, userId, refreshed);
const config = this.pollingConfigs.get(host.id);
if (config) {
config.host = refreshed;
config.statsConfig = this.parseStatsConfig(refreshed.statsConfig);
}
return refreshed;
}
private scheduleStatusPoll(
host: SSHHostWithCredentials,
viewerUserId?: string,
): void {
if (this.statusInFlight.has(host.id)) {
return;
}
this.statusInFlight.add(host.id);
void statusPollLimiter
.run(() => this.pollHostStatus(host, viewerUserId))
.catch((err) => {
statsLogger.error("Status polling failed", err, {
operation: "status_poll_unhandled",
hostId: host.id,
});
})
.finally(() => {
this.statusInFlight.delete(host.id);
});
}
private scheduleMetricsPoll(
host: SSHHostWithCredentials,
viewerUserId?: string,
): void {
if (this.metricsInFlight.has(host.id)) {
return;
}
this.metricsInFlight.add(host.id);
void metricsPollLimiter
.run(() => this.pollHostMetrics(host, viewerUserId))
.catch((err) => {
statsLogger.error("Metrics polling failed", err, {
operation: "metrics_poll_unhandled",
hostId: host.id,
});
})
.finally(() => {
this.metricsInFlight.delete(host.id);
});
}
private getGlobalDefaults(): {
statusCheckInterval: number;
metricsInterval: number;
@@ -309,14 +390,17 @@ class PollingManager {
this.pollingConfigs.set(host.id, config);
if (isTcpPingEnabled(statsConfig)) {
const intervalMs = statsConfig.statusCheckInterval * 1000;
const intervalMs = this.intervalWithJitter(
statsConfig.statusCheckInterval * 1000,
host.id,
);
this.pollHostStatus(host, viewerUserId);
this.scheduleStatusPoll(host, viewerUserId);
config.statusTimer = setInterval(() => {
const latestConfig = this.pollingConfigs.get(host.id);
if (latestConfig && isTcpPingEnabled(latestConfig.statsConfig)) {
this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId);
this.scheduleStatusPoll(latestConfig.host, latestConfig.viewerUserId);
}
}, intervalMs);
} else {
@@ -324,9 +408,27 @@ class PollingManager {
}
if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) {
const intervalMs = statsConfig.metricsInterval * 1000;
const intervalMs = this.intervalWithJitter(
statsConfig.metricsInterval * 1000,
host.id,
);
await this.pollHostMetrics(host, viewerUserId);
// First sample still awaited (gated) so callers can rely on a warm cache.
if (!this.metricsInFlight.has(host.id)) {
this.metricsInFlight.add(host.id);
try {
await metricsPollLimiter.run(() =>
this.pollHostMetrics(host, viewerUserId),
);
} catch (err) {
statsLogger.error("Metrics polling failed", err, {
operation: "metrics_poll_unhandled",
hostId: host.id,
});
} finally {
this.metricsInFlight.delete(host.id);
}
}
config.metricsTimer = setInterval(() => {
const latestConfig = this.pollingConfigs.get(host.id);
@@ -335,15 +437,10 @@ class PollingManager {
latestConfig.statsConfig.metricsEnabled &&
supportsMetrics(latestConfig.host)
) {
this.pollHostMetrics(
this.scheduleMetricsPoll(
latestConfig.host,
latestConfig.viewerUserId,
).catch((err) => {
statsLogger.error("Metrics polling failed", err, {
operation: "metrics_poll_unhandled",
hostId: host.id,
});
});
);
}
}, intervalMs);
} else {
@@ -356,7 +453,7 @@ class PollingManager {
viewerUserId?: string,
): Promise<void> {
const userId = viewerUserId || host.userId;
const refreshedHost = await fetchHostById(host.id, userId);
const refreshedHost = await this.resolveHostForPoll(host, userId);
if (!refreshedHost) {
return;
}
@@ -426,7 +523,7 @@ class PollingManager {
viewerUserId?: string,
): Promise<void> {
const userId = viewerUserId || host.userId;
const refreshedHost = await fetchHostById(host.id, userId);
const refreshedHost = await this.resolveHostForPoll(host, userId);
if (!refreshedHost) {
return;
}
@@ -581,6 +678,9 @@ class PollingManager {
this.metricsStore.delete(hostId);
}
}
hostPollCache.invalidate(hostId);
this.statusInFlight.delete(hostId);
this.metricsInFlight.delete(hostId);
}
stopMetricsOnly(hostId: number): void {
@@ -1843,6 +1943,7 @@ app.post("/host-updated", async (req, res) => {
}
try {
hostPollCache.invalidate(hostId);
const host = await fetchHostById(hostId, userId);
if (host) {
connectionPool.clearKeyConnections(getPoolKey(host));
+150 -75
View File
@@ -8,18 +8,29 @@ interface PooledConnection {
hostKey: string;
}
interface ConnectionWaiter {
resolve: (client: Client) => void;
reject: (error: Error) => void;
factory: () => Promise<Client>;
timer: NodeJS.Timeout;
}
const DEFAULT_MAX_CONNECTIONS_PER_HOST = 3;
const DEFAULT_MAX_WAIT_MS = 30_000;
const IDLE_MAX_AGE_MS = 10 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 2 * 60 * 1000;
class SSHConnectionPool {
private connections = new Map<string, PooledConnection[]>();
private maxConnectionsPerHost = 3;
private waiters = new Map<string, ConnectionWaiter[]>();
private maxConnectionsPerHost = DEFAULT_MAX_CONNECTIONS_PER_HOST;
private maxWaitMs = DEFAULT_MAX_WAIT_MS;
private cleanupInterval: NodeJS.Timeout;
constructor() {
this.cleanupInterval = setInterval(
() => {
this.cleanup();
},
2 * 60 * 1000,
);
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, CLEANUP_INTERVAL_MS);
}
private isConnectionHealthy(client: Client): boolean {
@@ -38,6 +49,126 @@ class SSHConnectionPool {
}
}
private removeUnhealthy(
key: string,
connections: PooledConnection[],
target: PooledConnection,
): PooledConnection[] {
sshLogger.warn("Removing unhealthy connection from pool", {
operation: "pool_remove_dead",
hostKey: key,
});
try {
target.client.end();
} catch {
// expected
}
const filtered = connections.filter((c) => c !== target);
this.connections.set(key, filtered);
return filtered;
}
private async createPooledClient(
key: string,
factory: () => Promise<Client>,
existing: PooledConnection[],
): Promise<Client> {
const client = await factory();
const pooled: PooledConnection = {
client,
lastUsed: Date.now(),
inUse: true,
hostKey: key,
};
existing.push(pooled);
this.connections.set(key, existing);
client.on("end", () => {
this.removeConnection(key, client);
});
client.on("close", () => {
this.removeConnection(key, client);
});
return client;
}
private enqueueWaiter(
key: string,
factory: () => Promise<Client>,
): Promise<Client> {
return new Promise<Client>((resolve, reject) => {
const timer = setTimeout(() => {
const queue = this.waiters.get(key);
if (queue) {
const idx = queue.findIndex((w) => w.timer === timer);
if (idx >= 0) queue.splice(idx, 1);
if (queue.length === 0) this.waiters.delete(key);
}
const err = new Error(
`SSH connection pool wait timed out after ${this.maxWaitMs}ms (${key})`,
);
sshLogger.warn("Connection pool wait timeout", {
operation: "pool_wait_timeout",
hostKey: key,
maxWaitMs: this.maxWaitMs,
});
reject(err);
}, this.maxWaitMs);
const waiter: ConnectionWaiter = { resolve, reject, factory, timer };
const queue = this.waiters.get(key) || [];
queue.push(waiter);
this.waiters.set(key, queue);
});
}
/** Hand a free connection (or new slot) to the next waiter, if any. */
private wakeWaiter(key: string): void {
const queue = this.waiters.get(key);
if (!queue || queue.length === 0) return;
let connections = this.connections.get(key) || [];
const available = connections.find((conn) => !conn.inUse);
if (available) {
if (!this.isConnectionHealthy(available.client)) {
connections = this.removeUnhealthy(key, connections, available);
// Fall through — may create or wait again.
} else {
const waiter = queue.shift()!;
if (queue.length === 0) this.waiters.delete(key);
else this.waiters.set(key, queue);
clearTimeout(waiter.timer);
available.inUse = true;
available.lastUsed = Date.now();
waiter.resolve(available.client);
return;
}
}
if (connections.length < this.maxConnectionsPerHost) {
const waiter = queue.shift()!;
if (queue.length === 0) this.waiters.delete(key);
else this.waiters.set(key, queue);
clearTimeout(waiter.timer);
this.createPooledClient(key, waiter.factory, connections)
.then(waiter.resolve)
.catch(waiter.reject);
return;
}
}
private rejectWaiters(key: string, reason: string): void {
const queue = this.waiters.get(key);
if (!queue) return;
this.waiters.delete(key);
for (const waiter of queue) {
clearTimeout(waiter.timer);
waiter.reject(new Error(reason));
}
}
async getConnection(
key: string,
factory: () => Promise<Client>,
@@ -47,17 +178,7 @@ class SSHConnectionPool {
const available = connections.find((conn) => !conn.inUse);
if (available) {
if (!this.isConnectionHealthy(available.client)) {
sshLogger.warn("Removing unhealthy connection from pool", {
operation: "pool_remove_dead",
hostKey: key,
});
try {
available.client.end();
} catch {
// expected
}
connections = connections.filter((c) => c !== available);
this.connections.set(key, connections);
connections = this.removeUnhealthy(key, connections, available);
} else {
available.inUse = true;
available.lastUsed = Date.now();
@@ -66,63 +187,10 @@ class SSHConnectionPool {
}
if (connections.length < this.maxConnectionsPerHost) {
const client = await factory();
const pooled: PooledConnection = {
client,
lastUsed: Date.now(),
inUse: true,
hostKey: key,
};
connections.push(pooled);
this.connections.set(key, connections);
client.on("end", () => {
this.removeConnection(key, client);
});
client.on("close", () => {
this.removeConnection(key, client);
});
return client;
return this.createPooledClient(key, factory, connections);
}
return new Promise((resolve) => {
const checkAvailable = () => {
const conns = this.connections.get(key) || [];
const avail = conns.find((conn) => !conn.inUse);
if (avail) {
if (!this.isConnectionHealthy(avail.client)) {
try {
avail.client.end();
} catch {
// expected
}
const filtered = conns.filter((c) => c !== avail);
this.connections.set(key, filtered);
factory().then((client) => {
const pooled: PooledConnection = {
client,
lastUsed: Date.now(),
inUse: true,
hostKey: key,
};
filtered.push(pooled);
this.connections.set(key, filtered);
client.on("end", () => this.removeConnection(key, client));
client.on("close", () => this.removeConnection(key, client));
resolve(client);
});
} else {
avail.inUse = true;
avail.lastUsed = Date.now();
resolve(avail.client);
}
} else {
setTimeout(checkAvailable, 100);
}
};
checkAvailable();
});
return this.enqueueWaiter(key, factory);
}
releaseConnection(key: string, client: Client): void {
@@ -131,6 +199,7 @@ class SSHConnectionPool {
if (pooled) {
pooled.inUse = false;
pooled.lastUsed = Date.now();
this.wakeWaiter(key);
}
}
@@ -143,6 +212,8 @@ class SSHConnectionPool {
} else {
this.connections.set(key, filtered);
}
// A slot or idle connection may now be free for waiters.
this.wakeWaiter(key);
}
clearKeyConnections(key: string): void {
@@ -155,15 +226,15 @@ class SSHConnectionPool {
}
}
this.connections.delete(key);
this.rejectWaiters(key, `SSH connection pool cleared for ${key}`);
}
private cleanup(): void {
const now = Date.now();
const maxAge = 10 * 60 * 1000;
for (const [hostKey, connections] of this.connections.entries()) {
const activeConnections = connections.filter((conn) => {
if (!conn.inUse && now - conn.lastUsed > maxAge) {
if (!conn.inUse && now - conn.lastUsed > IDLE_MAX_AGE_MS) {
try {
conn.client.end();
} catch {
@@ -187,10 +258,14 @@ class SSHConnectionPool {
} else {
this.connections.set(hostKey, activeConnections);
}
this.wakeWaiter(hostKey);
}
}
clearAllConnections(): void {
for (const key of [...this.waiters.keys()]) {
this.rejectWaiters(key, "SSH connection pool destroyed");
}
for (const connections of this.connections.values()) {
for (const conn of connections) {
try {