shield-bridge-sdk
Version:
201 lines • 8.02 kB
JavaScript
import * as Comlink from 'comlink';
/** Default maximum number of workers in the pool */
export const DEFAULT_POOL_SIZE = 5;
/** How long (ms) an idle worker stays alive before being reaped. Default: 5 minutes */
export const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
/**
* A bounded, lazy-growing pool of Comlink-wrapped Sapling Web Workers.
*
* Design goals:
* - **Lazy creation** – workers are only spawned when checkout() cannot find an
* idle worker and the pool has capacity.
* - **Bounded concurrency** – at most `maxSize` workers exist simultaneously.
* - **Worker reuse** – checked-in workers stay alive with their heavy sapling
* params already initialised, avoiding repeated ~50 MB fetches.
* - **Back-pressure** – when all workers are busy the caller awaits a Promise
* that resolves as soon as any worker is returned.
* - **Idle reaping** – a periodic sweep terminates workers that have been idle
* longer than `idleTimeoutMs`.
*/
export class SaplingWorkerPool {
constructor(
/** Max workers that can exist at once */
maxSize,
/** Factory that creates a new Comlink-wrapped worker */
createWorkerFn,
/** How long (ms) an idle worker stays alive before being reaped */
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS) {
this.maxSize = maxSize;
this.createWorkerFn = createWorkerFn;
this.idleTimeoutMs = idleTimeoutMs;
this.pool = [];
this.pendingCheckouts = [];
this.idleTimer = null;
/** Whether the pool has been destroyed */
this.destroyed = false;
/**
* Number of workers currently being created (awaiting createWorkerFn).
* This is used to prevent the pool from exceeding maxSize when multiple
* concurrent checkout() calls race past the capacity check before the
* first worker creation completes and pushes to the pool.
*/
this.creating = 0;
// Start idle reaper
this.startIdleReaper();
}
// ─── Public API ─────────────────────────────────────────────────────
/**
* Acquire an idle worker from the pool.
*
* - If an idle worker exists it is returned immediately.
* - If the pool has room, a new worker is created and returned.
* - Otherwise the caller is queued until a worker becomes available.
*
* The returned `PoolEntry` **must** be given back via `release()` when done.
*/
async checkout() {
if (this.destroyed) {
throw new Error('SaplingWorkerPool has been destroyed');
}
// 1. Try to find an idle worker
const idle = this.pool.find((w) => !w.busy);
if (idle) {
idle.busy = true;
return idle;
}
// 2. Grow the pool if under capacity (including in-flight creations)
if (this.pool.length + this.creating < this.maxSize) {
this.creating += 1;
let worker;
try {
worker = await this.createWorkerFn();
}
catch (err) {
this.creating -= 1;
throw err;
}
this.creating -= 1;
const entry = {
worker,
busy: true,
paramsLoaded: false,
lastUsed: Date.now(),
};
this.pool.push(entry);
// Fire-and-forget param preload so it's ready before any proof generation
worker
.preloadSaplingParams()
.then(() => {
entry.paramsLoaded = true;
})
.catch((err) => {
console.error('[SaplingWorkerPool] Failed to preload sapling params:', err);
});
return entry;
}
// 3. Pool is at capacity — wait for a worker to be returned
return new Promise((resolve) => {
this.pendingCheckouts.push({ resolve });
});
}
/**
* Return a worker to the pool. Any queued `checkout()` callers are served
* immediately (FIFO).
*/
release(entry) {
// eslint-disable-next-line no-param-reassign
entry.busy = false;
// eslint-disable-next-line no-param-reassign
entry.lastUsed = Date.now();
// If someone is waiting, hand the worker over directly
const waiter = this.pendingCheckouts.shift();
if (waiter) {
// eslint-disable-next-line no-param-reassign
entry.busy = true;
waiter.resolve(entry);
}
}
/**
* Destroy all workers and reject any pending checkouts.
* After calling this the pool is unusable.
*/
destroy() {
this.destroyed = true;
// Stop the idle reaper
if (this.idleTimer) {
clearInterval(this.idleTimer);
this.idleTimer = null;
}
// Terminate every worker
this.pool.forEach((poolEntry) => {
try {
poolEntry.worker[Comlink.releaseProxy]();
}
catch {
// Worker may already be terminated
}
});
this.pool = [];
this.pendingCheckouts = [];
}
// ─── Diagnostics ────────────────────────────────────────────────────
/** Current number of workers (busy + idle) */
get size() {
return this.pool.length;
}
/** Number of workers currently checked out */
get busyCount() {
return this.pool.filter((w) => w.busy).length;
}
/** Number of idle workers ready for checkout */
get idleCount() {
return this.pool.filter((w) => !w.busy).length;
}
/** Number of callers waiting for a worker */
get pendingCount() {
return this.pendingCheckouts.length;
}
// ─── Internals ──────────────────────────────────────────────────────
/**
* Periodically terminate workers that have been idle too long to reclaim
* memory (each worker holds ~60-80 MB of sapling WASM + params).
*/
startIdleReaper() {
// Check every minute
const REAPER_INTERVAL_MS = 60 * 1000;
this.idleTimer = setInterval(() => {
if (this.destroyed)
return;
const now = Date.now();
// Never reap the last worker — keep at least one warm
const minPoolSize = 1;
// Identify idle workers eligible for reaping
const toReap = this.pool.filter((poolEntry) => !poolEntry.busy && now - poolEntry.lastUsed > this.idleTimeoutMs);
// Don't reap below minimum pool size
const maxReapable = this.pool.length - minPoolSize;
const entriesToReap = toReap.slice(0, Math.max(0, maxReapable));
entriesToReap.forEach((poolEntry) => {
try {
poolEntry.worker[Comlink.releaseProxy]();
}
catch {
// Worker may already be terminated
}
const idx = this.pool.indexOf(poolEntry);
if (idx !== -1) {
this.pool.splice(idx, 1);
}
});
if (entriesToReap.length > 0) {
console.log(`[SaplingWorkerPool] Reaped ${entriesToReap.length} idle worker(s). Pool size: ${this.pool.length}`);
}
}, REAPER_INTERVAL_MS);
// Don't keep the process alive just for the reaper (Node.js)
if (this.idleTimer &&
typeof this.idleTimer === 'object' &&
'unref' in this.idleTimer) {
this.idleTimer.unref();
}
}
}
//# sourceMappingURL=workerPool.js.map