128 lines
5.1 KiB
TypeScript
128 lines
5.1 KiB
TypeScript
import { Worker } from 'node:worker_threads';
|
|
import type { ImportedDataset } from '../src/domain/dataset';
|
|
import { ImportError } from '../src/shared/errors';
|
|
import type { ImportExecutionRequest, ImportExecutor } from './import-executor';
|
|
|
|
interface WorkerResult {
|
|
ok: boolean;
|
|
dataset?: ImportedDataset;
|
|
error?: { message: string; diagnostics: ConstructorParameters<typeof ImportError>[1] };
|
|
}
|
|
|
|
interface QueuedJob {
|
|
request: ImportExecutionRequest;
|
|
resolve(value: ImportedDataset): void;
|
|
reject(reason: unknown): void;
|
|
}
|
|
|
|
export interface NodeImportWorkerExecutorOptions {
|
|
workerUrl: URL;
|
|
timeoutMs?: number;
|
|
maximumConcurrent?: number;
|
|
maximumQueued?: number;
|
|
maximumQueuedBytes?: number;
|
|
maximumOldGenerationMb?: number;
|
|
maximumYoungGenerationMb?: number;
|
|
}
|
|
|
|
export class NodeImportWorkerExecutor implements ImportExecutor {
|
|
private readonly timeoutMs: number;
|
|
private readonly maximumConcurrent: number;
|
|
private readonly maximumQueued: number;
|
|
private readonly maximumQueuedBytes: number;
|
|
private readonly maximumOldGenerationMb: number;
|
|
private readonly maximumYoungGenerationMb: number;
|
|
private readonly queue: QueuedJob[] = [];
|
|
private readonly workers = new Set<Worker>();
|
|
private active = 0;
|
|
private queuedBytes = 0;
|
|
private closed = false;
|
|
|
|
constructor(private readonly options: NodeImportWorkerExecutorOptions) {
|
|
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
this.maximumConcurrent = options.maximumConcurrent ?? 2;
|
|
this.maximumQueued = options.maximumQueued ?? 8;
|
|
this.maximumQueuedBytes = options.maximumQueuedBytes ?? 256 * 1024 * 1024;
|
|
this.maximumOldGenerationMb = options.maximumOldGenerationMb ?? 512;
|
|
this.maximumYoungGenerationMb = options.maximumYoungGenerationMb ?? 64;
|
|
}
|
|
|
|
execute(request: ImportExecutionRequest): Promise<ImportedDataset> {
|
|
if (this.closed) return Promise.reject(this.executionError('parser-executor-closed', 'Der Parserdienst wird beendet.'));
|
|
const requestBytes = request.source.bytes.byteLength;
|
|
if (this.active >= this.maximumConcurrent
|
|
&& (this.queue.length >= this.maximumQueued || this.queuedBytes + requestBytes > this.maximumQueuedBytes)) {
|
|
return Promise.reject(this.executionError('parser-capacity-limit', 'Die lokale Parserwarteschlange ist ausgelastet. Bitte später erneut versuchen.'));
|
|
}
|
|
return new Promise<ImportedDataset>((resolve, reject) => {
|
|
this.queue.push({ request, resolve, reject });
|
|
this.queuedBytes += requestBytes;
|
|
this.pump();
|
|
});
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
this.closed = true;
|
|
const error = this.executionError('parser-executor-closed', 'Der Parserdienst wurde beendet.');
|
|
this.queue.splice(0).forEach(job => job.reject(error));
|
|
this.queuedBytes = 0;
|
|
await Promise.all([...this.workers].map(worker => worker.terminate().catch(() => undefined)));
|
|
}
|
|
|
|
private executionError(code: string, message: string): ImportError {
|
|
return new ImportError(message, [{ severity: 'error', code, message }]);
|
|
}
|
|
|
|
private pump(): void {
|
|
while (!this.closed && this.active < this.maximumConcurrent && this.queue.length) {
|
|
const job = this.queue.shift()!;
|
|
this.queuedBytes -= job.request.source.bytes.byteLength;
|
|
this.active += 1;
|
|
void this.run(job).finally(() => {
|
|
this.active -= 1;
|
|
this.pump();
|
|
});
|
|
}
|
|
}
|
|
|
|
private run(job: QueuedJob): Promise<void> {
|
|
return new Promise<void>(resolveJob => {
|
|
const worker = new Worker(this.options.workerUrl, {
|
|
workerData: job.request,
|
|
resourceLimits: {
|
|
maxOldGenerationSizeMb: this.maximumOldGenerationMb,
|
|
maxYoungGenerationSizeMb: this.maximumYoungGenerationMb,
|
|
stackSizeMb: 8
|
|
}
|
|
});
|
|
this.workers.add(worker);
|
|
let settled = false;
|
|
const settle = (action: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
this.workers.delete(worker);
|
|
action();
|
|
void worker.terminate().finally(resolveJob);
|
|
};
|
|
const timer = setTimeout(() => settle(() => job.reject(this.executionError(
|
|
'parser-time-limit', `Der Parser wurde nach ${this.timeoutMs} Millisekunden beendet.`
|
|
))), this.timeoutMs);
|
|
worker.once('message', (result: WorkerResult) => settle(() => {
|
|
if (result.ok && result.dataset) job.resolve(result.dataset);
|
|
else job.reject(new ImportError(result.error?.message ?? 'Parser-Worker fehlgeschlagen.', result.error?.diagnostics ?? []));
|
|
}));
|
|
worker.once('error', error => settle(() => {
|
|
const outOfMemory = 'code' in error && error.code === 'ERR_WORKER_OUT_OF_MEMORY';
|
|
job.reject(this.executionError(
|
|
outOfMemory ? 'parser-memory-limit' : 'parser-worker-error',
|
|
outOfMemory ? 'Der Parser hat sein Speicherlimit überschritten.' : 'Der isolierte Parserprozess ist fehlgeschlagen.'
|
|
));
|
|
}));
|
|
worker.once('exit', code => {
|
|
if (!settled) settle(() => job.reject(this.executionError('parser-worker-exit', `Der Parser-Worker wurde mit Status ${code} beendet.`)));
|
|
});
|
|
});
|
|
}
|
|
}
|