210 lines
6.1 KiB
TypeScript
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 };
|