Files
suaveplan/packages/devtools/tooling/sandbox/src/pool/pool.ts
2026-05-27 23:29:53 +01:00

210 lines
6.1 KiB
TypeScript

import { SANDBOX_CODES } from "../error-codes/error-codes.js";
import { recordPoolAcquisition } from "../telemetry/telemetry.js";
import type {
Clock,
ExecutionContext,
SandboxEngine,
SandboxEngineConfig,
SandboxOptions,
SandboxResult,
} from "../types/sandbox.types.js";
/** Factory that builds engines on demand. */
export type EngineFactory = (config: SandboxEngineConfig) => SandboxEngine;
/** Pool configuration. */
export interface SandboxPoolConfig {
readonly minSize: number;
readonly maxSize: number;
readonly engineConfig: SandboxEngineConfig;
readonly factory: EngineFactory;
readonly idleTimeoutMs: number;
readonly maxExecutionsPerEngine: number;
readonly clock?: Clock;
}
interface PooledEngine {
engine: SandboxEngine;
inUse: boolean;
executionCount: number;
lastUsed: number;
createdAt: number;
}
const DEFAULTS: Omit<SandboxPoolConfig, "engineConfig" | "factory"> = {
minSize: 2,
maxSize: 10,
idleTimeoutMs: 60_000,
maxExecutionsPerEngine: 1000,
};
/**
* Pool of pre-warmed sandbox engines. The pool grows lazily up to `maxSize`,
* recycles engines past `maxExecutionsPerEngine`, and reclaims idle engines
* above `minSize` whose `lastUsed` exceeds `idleTimeoutMs` (relative to the
* injectable {@link Clock}, defaulting to `Date.now`).
*/
export class SandboxPool {
private readonly config: SandboxPoolConfig;
private readonly clock: Clock;
private pool: PooledEngine[] = [];
public constructor(
config: Pick<SandboxPoolConfig, "engineConfig" | "factory"> &
Partial<SandboxPoolConfig>
) {
this.config = { ...DEFAULTS, ...config };
this.clock = this.config.clock ?? (() => Date.now());
this.warmUp();
}
/** Execute code on a pooled engine. */
public async execute<T = unknown>(
code: string,
context?: ExecutionContext,
options?: SandboxOptions
): Promise<SandboxResult<T>> {
const pooled = this.acquire();
if (pooled === null) {
recordPoolAcquisition("exhausted");
return {
success: false,
result: undefined,
error: `Sandbox pool exhausted (${SANDBOX_CODES.POOL_EXHAUSTED})`,
duration: 0,
memoryUsed: 0,
logs: [],
};
}
try {
const result = await pooled.engine.execute<T>(
code,
context,
options
);
pooled.executionCount += 1;
pooled.lastUsed = this.clock();
return result;
} finally {
this.release(pooled);
}
}
/** Current pool size. */
public size(): number {
return this.pool.length;
}
/** Idle engines. */
public available(): number {
return this.pool.filter((p) => !p.inUse).length;
}
/** In-use engines. */
public inUse(): number {
return this.pool.filter((p) => p.inUse).length;
}
/** Aggregate pool stats. */
public stats(): {
size: number;
available: number;
inUse: number;
totalExecutions: number;
} {
let totalExecutions = 0;
for (const p of this.pool) totalExecutions += p.executionCount;
return {
size: this.pool.length,
available: this.available(),
inUse: this.inUse(),
totalExecutions,
};
}
/** Bring the pool up to `minSize`. */
public warmUp(): void {
while (this.pool.length < this.config.minSize) {
this.pool.push(this.createPooled());
}
}
/** Single cleanup tick — reclaims idle engines above `minSize`. */
public cleanupTick(): void {
const now = this.clock();
const survivors: PooledEngine[] = [];
let removed = 0;
for (const pooled of this.pool) {
if (
!pooled.inUse &&
this.pool.length - removed > this.config.minSize &&
now - pooled.lastUsed > this.config.idleTimeoutMs
) {
pooled.engine.destroy();
removed += 1;
continue;
}
survivors.push(pooled);
}
this.pool = survivors;
}
/** Destroy every engine in the pool. */
public destroy(): void {
for (const pooled of this.pool) {
pooled.engine.destroy();
}
this.pool = [];
}
private acquire(): PooledEngine | null {
const idle = this.pool.find((p) => !p.inUse);
if (idle !== undefined) {
idle.inUse = true;
recordPoolAcquisition("acquired");
return idle;
}
if (this.pool.length < this.config.maxSize) {
const pooled = this.createPooled();
pooled.inUse = true;
this.pool.push(pooled);
recordPoolAcquisition("created");
return pooled;
}
return null;
}
private release(pooled: PooledEngine): void {
pooled.inUse = false;
if (pooled.executionCount >= this.config.maxExecutionsPerEngine) {
this.recycle(pooled);
}
}
private recycle(pooled: PooledEngine): void {
pooled.engine.destroy();
// Replace in-place: copy a fresh engine onto the same PooledEngine
// wrapper so the array reference stays valid for any other code
// that holds it. We rotate every field so the recycled entry is
// indistinguishable from a freshly created one.
const fresh = this.createPooled();
pooled.engine = fresh.engine;
pooled.executionCount = 0;
pooled.lastUsed = fresh.lastUsed;
pooled.createdAt = fresh.createdAt;
}
private createPooled(): PooledEngine {
const engine = this.config.factory(this.config.engineConfig);
return {
engine,
inUse: false,
executionCount: 0,
lastUsed: this.clock(),
createdAt: this.clock(),
};
}
}
export { DEFAULTS as DEFAULT_POOL_CONFIG };