@jovian/type-tools
Version:
TypeTools is a Typescript library for providing extensible tooling runtime validations and type helpers.
274 lines (259 loc) • 10.1 kB
text/typescript
/*
* Copyright 2014-2021 Jovian, all rights reserved.
*/
import { ChildProcess, spawn, execSync } from 'child_process';
import { v4 as uuidv4 } from 'uuid';
import { Class, ix, promise } from '../../index';
import * as os from 'os';
import { ProcessExit } from './process.exit.handler';
export type AsyncActionHandler = (payload: string, worker?: AsyncWorkerExecutor,
callId?: string, action?: string) => (string | Promise<string>);
export interface AsyncActionHandlers {
[actionName: string]: AsyncActionHandler;
}
export interface AsyncWorkerConfig {
workerFile?: string;
additionalEnv?: {[env: string]: string};
disregardParentEnv?: boolean;
nodeCommand?: string;
}
export class AsyncWorkerClient extends ix.Entity {
static nodeArgsDefault: string[] = [
'--enable-source-maps',
'--max-old-space-size=262144',
];
static nodeArgsActive: string[] = AsyncWorkerClient.nodeArgsDefault;
static nullAction() {}
workerData: any;
proc: ChildProcess;
responseFor = '';
handlerMap: {[name: string]: (msg: string, name: string) => any} = {};
invokeMap: {[callId: string]: (msg: string) => any} = {};
initPromise: Promise<void> = null;
initResolver: any;
terminating = false;
terminationResolver: () => any = null;
terminated = false;
duration = null;
startTime = Date.now();
endTime: number = null;
onterminate: (() => any)[] = [];
constructor(workerData: any, workerConfig: AsyncWorkerConfig) {
super('async-worker-client');
if (!workerData) { workerData = {}; }
this.initPromise = new Promise<void>(resolve => { this.initResolver = resolve; });
workerData.workerFile = workerConfig.workerFile;
workerData.workerConfig = JSON.parse(JSON.stringify(workerConfig));
this.workerData = workerData;
const nodeFlags: string[] = workerData.nodeFlags ? workerData.nodeFlags : [];
const callArgs = AsyncWorkerClient.nodeArgsActive.concat(nodeFlags, [workerConfig.workerFile]);
const envArg: {[envVar: string]: string} = workerConfig.disregardParentEnv ? {} : {...process.env};
if (workerConfig.additionalEnv) { Object.assign(envArg, workerConfig.additionalEnv); }
envArg.WORKER_DATA_BASE64 = Buffer.from(JSON.stringify(workerData), 'utf8').toString('base64');
const nodeCommand = workerConfig.nodeCommand ? workerConfig.nodeCommand : 'node';
this.proc = spawn(nodeCommand, callArgs, {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: envArg,
});
this.addDefaultHandlers();
this.proc.on('message', messageSerial => {
const message = messageSerial as string;
if (this.responseFor) {
this.handleResponse(this.responseFor, message);
} else {
this.responseFor = message;
}
});
}
call<T = string, R = T>(action: string, payload: string = '', parser?: (response: string) => R) {
const callId = `${action}::${uuidv4()}`;
return new Promise<R>(async resolve => {
if (this.terminating || this.terminated) { return resolve(null); }
if (!payload) { payload = ''; }
if (!parser) { parser = response => response as unknown as R; }
await Promise.resolve(this.initPromise);
this.invokeMap[callId] = response => {
try {
if (typeof response === 'string' && response.startsWith('$')) {
switch (response) {
case '$__unhandled_action':
console.warn(new Error(`Worker[${this.workerData.file}] with unhandled call request. action=${action}, callId=${callId}`));
resolve(null);
default: resolve(null);
}
}
if (response === '' || response === null) {
resolve(null);
} else {
try {
const res = parser(response);
resolve(res !== null ? res : null);
} catch (e) {
resolve(null);
}
}
} catch (e) { resolve(null); }
};
this.proc.send(callId);
this.proc.send(payload);
});
}
import(scriptFile: string) {
return this.call<boolean>(`$__import`, scriptFile, r => r ? true : false);
}
terminate(exitCode: number = 0) {
this.call('$__terminate', exitCode + '');
return promise(async resolve => {
this.terminationResolver = resolve;
});
}
setDefaultHandler(name: string, handler: (message: string, name: string) => any) {
if (!name.startsWith('$')) {
throw new Error(`Default handler name must start with $`);
}
this.handlerMap[name] = handler;
}
handleResponse(callId: string, message: string) {
this.responseFor = '';
if (callId.startsWith('$')) {
const hcb = this.handlerMap[callId];
if (hcb) {
hcb(message, callId);
delete this.invokeMap[callId];
} else {
console.warn(new Error(`Worker[${this.workerData.file}] unknown callId=${callId}`));
}
return;
}
const cb = this.invokeMap[callId];
if (cb) { cb(message); delete this.invokeMap[callId]; }
}
private addDefaultHandlers() {
this.setDefaultHandler('$__init', (message, name) => {
if (this.initResolver) { this.initResolver(); }
this.initResolver = this.initPromise = null;
});
this.setDefaultHandler('$__termination_set', (message, name) => {
this.terminating = true;
for (const cb2 of this.onterminate) { try { cb2(); } catch (e) {} }
});
this.setDefaultHandler('$__terminated', (message, name) => {
this.terminated = true;
this.endTime = Date.now();
this.duration = this.endTime - this.startTime;
if (this.terminationResolver) { this.terminationResolver(); }
});
}
}
export class AsyncWorkerExecutor extends ix.Entity {
terminating = false;
terminated = false;
mainScope: ix.MajorScope;
workerData: any;
data: {[key: string]: any} = {};
requestFor = '';
invokeMap: {[callId: string]: (msg: string) => any} = {};
customAction: {[actionName: string]: AsyncActionHandler} = {};
constructor(workerData: any) {
super('async-worker-logic');
process.on('unhandledRejection', e => {
// tslint:disable-next-line: no-console
console.warn('[WARNING] UnhandledRejection:', e);
});
this.workerData = workerData;
process.on('message', async messageSerial => {
const message = messageSerial as string;
if (this.requestFor) {
this.handleRequest(this.requestFor, message);
} else {
this.requestFor = message;
}
});
const scope = workerData.scopeName ? workerData.scopeName : 'unnamed_scope';
this.mainScope = new ix.MajorScope(scope + `(${process.pid})`);
if (workerData.coreAffinity !== null && workerData.coreAffinity !== undefined) {
let core = workerData.coreAffinity;
if (core === 'auto' && workerData.workerId !== null && workerData.workerId !== undefined) {
core = (workerData.workerId % os.cpus().length) + '';
}
if (process.platform === 'linux') {
execSync(`taskset -cp ${core} ${process.pid}`, {stdio: 'inherit'});
}
}
}
getSelf() { return this; }
setAsReady() { this.returnCall('$__init'); }
returnCall(callId: string, response?: string) {
if (!response) { response = ''; }
if (this.terminated) { return; }
if (this.terminating && !callId.startsWith('$__termin')) { return; }
process.send(callId);
process.send(response);
return true;
}
addCustomAction(actionName: string, handler: AsyncActionHandler) {
this.customAction[actionName] = handler;
}
async handleRequest(callId: string, payload?: string) {
this.requestFor = '';
const action = callId.split('::')[0];
switch (action) {
case '$__terminate': {
if (this.terminating) { break; }
this.terminating = true;
const exitCode = payload ? parseInt(payload, 10) : 0;
ProcessExit.addEndingTask(this.ontermination());
setTimeout(() => {
ProcessExit.gracefully(exitCode, 1000, () => {
this.returnCall('$__terminated');
this.terminated = true;
});
}, 10);
return this.returnCall('$__termination_set');
}
case '$__import': {
try {
const module = require(payload);
if (module.workerExtension) {
for (const actionName of Object.keys(module.workerExtension)) {
this.addCustomAction(actionName, module.workerExtension[actionName]);
}
}
} catch (e) {}
return this.returnCall(callId, `$__import(${payload})`);
}
}
const handleResultProm = this.handleAction(callId, action, payload);
if (!handleResultProm) {
return this.returnCall(callId, '$__unhandled_action');
}
const res = await handleResultProm;
if (res) { return res; }
if (!res && this.customAction[action]) {
let resStr = await Promise.resolve(this.customAction[action](payload, this, callId, action));
if (!resStr) { resStr = ''; }
return this.returnCall(callId, resStr);
}
console.warn(new Error(`Worker[${this.workerData.workerFile.split('/').pop()}] with unhandled call request. action=${action}, callId=${callId}`));
}
async handleAction(callId: string, action: string, payload?: string): Promise<any> {}
async ontermination() {}
}
export function startWorker(workerFile: string, logic: Class<AsyncWorkerExecutor>) {
if (process.env.WORKER_DATA_BASE64) {
const workerData = JSON.parse(Buffer.from(process.env.WORKER_DATA_BASE64, 'base64').toString('utf8'));
if (workerData.workerFile === workerFile) {
return new logic(workerData).getSelf();
}
}
return false;
}
export interface AsyncWorkerFleet<T extends AsyncWorkerClient> {
workers: T[];
}
export function workerFleetAddMember<T extends AsyncWorkerClient>(fleet: AsyncWorkerFleet<T>, workerClass: Class<T>, workerData?: {[key: string]: any; }) {
if (!workerData) { workerData = {}; }
const worker = new workerClass(workerData);
fleet.workers.push(worker);
return worker;
}