UNPKG

exthos

Version:

stream processing in nodejs using the power of golang

1,390 lines (1,290 loc) 49.4 kB
import * as constants from "../constants.js"; import * as path from "path"; import { tmpdir, EOL } from "os"; import * as fs from "fs"; import { randomUUID } from "crypto"; import { Deferred, sleep, getISOStringLocalTz, getCaller, formatErrorForEvent, standardizeAxiosErrors, } from "../utils/utils.js"; import { execaCommand, ExecaChildProcess, execaCommandSync } from "execa"; import * as net from "net"; import axios, { AxiosResponse } from "axios"; import axiosRetry from "axios-retry"; import { Stream } from "../stream/stream.js"; import debug from "debug"; import * as stream from "stream"; import { promises as streamPromises } from "stream"; import { EngineProcessAPI } from "./engineProcessAPI.js"; import { once } from "events"; // TODO: remove and eventemitter2.once? import { Mutex } from "async-mutex"; import { clearInterval } from "timers"; import EventEmitter2, { ListenerFn } from "eventemitter2"; import merge from "lodash.merge"; // import { engineConfig, engineExtraConfig } from "../config/config.js"; import config from "../config/config.js"; import { EngineConfig, EngineExtraConfig } from "../config/types.js"; /** * the eventObj type. * - an event related to the stream will contain the stream property * - an event related to fatal/error/warn will contain the error property */ type EventObj = { msg: string; time: string; stream?: Stream; error?: any; level?: string; }; type engineEventsTypes = | "engine.active" | "engine.inactive" | "engine.warn" | "engine.error" | "engine.fatal" | "engine.stream.add" | "engine.stream.update" | "engine.stream.remove" | "engine.stream.error" | "engineProcess.stream.fatal" | "engineProcess.stream.error" | "engineProcess.stream.warn" | "engineProcess.stream.info" | "engineProcess.stream.debug" | "engineProcess.stream.trace"; /** * Note: all listeners get an additional argument that specifies some details */ enum engineEventsEnums { /** * wildcards work */ "engine.**" = "engine.**", // both are the same "engine.*.*" = "engine.*.*", "engine.active" = "engine.active", "engine.inactive" = "engine.inactive", "engine.warn" = "engine.warn", "engine.error" = "engine.error", // an error occured with the engine "engine.fatal" = "engine.fatal", // engine is stopped when this event is received /** * engine events related to a stream * the eventObj will always contain the stream object */ "engine.stream.add" = "engine.stream.add", "engine.stream.update" = "engine.stream.update", "engine.stream.remove" = "engine.stream.remove", "engine.stream.error" = "engine.stream.error", // an error occured with the engine while working on a stream e.g. add/update etc. /** * events from stream's logs as they are emitted by the engineProcess */ "engineProcess.stream.fatal" = "engineProcess.stream.fatal", // fatal will .remove() the stream from the engine "engineProcess.stream.error" = "engineProcess.stream.error", "engineProcess.stream.warn" = "engineProcess.stream.warn", "engineProcess.stream.info" = "engineProcess.stream.info", "engineProcess.stream.debug" = "engineProcess.stream.debug", "engineProcess.stream.trace" = "engineProcess.stream.trace", } class Engine extends EngineProcessAPI { readonly #engineConfigFilePath: string = path.join( tmpdir(), "exthos_engine_conf_" + randomUUID() + ".json" ); #engineConfig!: EngineConfig; #engineExtraConfig!: EngineExtraConfig; #engineProcess!: ExecaChildProcess<string>; #abortController = new AbortController(); // must be more than _mgmtEventsFreqMs #mgmtEventsFreqMs: number = 2000; public waitForActiveEventMs: number = 5000; // must be more than 2-3 secs to give time for engine to turn active #keepAliveInterval!: NodeJS.Timeout; /** * debug is used heavily; * exthos:engine:debugLog - give general debug logs * exthos:eventLog - print events as they are emitted * exthos:engine:traceLog - prints trace information showing flow of code */ // TODO: get base debug from utils, and make thsese static #debug = debug("exthos"); #debugLog = this.#debug.extend("engine").extend("debugLog"); #traceLog = this.#debug.extend("engine").extend("traceLog"); // used to print trace lines e.g. line numbers #eventLog = this.#debug.extend("eventLog"); // used to print trace lines e.g. line numbers #eventNameToEventLog: Partial<Record<engineEventsTypes, debug.Debugger>> = {}; // used to set the debug extend only once per eventName #isActive: boolean = false; // engine uses the /ping api to change this state #engineConstrStartStopMutex = new Mutex(); #engineStreamAddUpdateRemoveMutex = new Mutex(); #engineUpdateConfigOptionsMutex = new Mutex(); #constructorDone = new Deferred(); // #isLocal!: boolean; // #debugNamespace: string = ""; #scheme: "http" | "https" = "http"; #tempLocalServer!: net.Server; // used to verify the IP and Port #streamsMap: { [key: string]: Stream } = {}; #benthosEXEFullPath: string = "/tmp"; public engineEvents = engineEventsEnums; /** * * @param engineConfig * @param engineExtraConfig * isLocal is defaulted to true */ constructor( engineConfig: Partial<EngineConfig> = {}, engineExtraConfig: Partial<EngineExtraConfig> = {} ) { super(); let self = this; let caller = getCaller(); self.#traceLog(`engine constructor called from: ${caller}`); self.#engineConstrStartStopMutex.runExclusive(() => { self.#traceLog(`engine constructor mutex acquired from: ${caller}`); try { // not using updateEngineConfigs so as to bypass the _constructorDone.promise await in it self.#setEngineConfig(engineConfig).then((_) => { self.#setEngineExtraConfig(engineExtraConfig).then((_) => { self.#createAxiosInstance().then((_) => { self.#constructorDone.resolve(); }); }); }); } catch (e: any) { self.emit(self.engineEvents["engine.fatal"], { msg: "engine constructor failed", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); } // engine.active/inactive to mutate the isActive on the engine self.on(self.engineEvents["engine.active"], () => { self.#isActive = true; }); self.on(self.engineEvents["engine.inactive"], () => { self.#isActive = false; }); // currently benthos gives a 503, which we conver to "fatal" - maybe we shoudnt! // self.on(self.engineEvents["engineProcess.stream.fatal"], (eventObj: EventObj) => { // if (eventObj.stream) { // self.remove(eventObj.msg, eventObj.stream) // } // }) self.on(self.engineEvents["engine.fatal"], (eventObj: EventObj) => { self.stop(eventObj.msg); }); }); } // override emit to allow only engineEventsTypes public emit: ( event: engineEventsTypes, eventObj: EventObj, ...values: any[] ) => boolean = ( event: engineEventsTypes, eventObj: EventObj, ...values: any[] ) => { let self = this; try { // eventLog the event if (!self.#eventNameToEventLog[event]) { self.#eventNameToEventLog[event] = self.#eventLog.extend(event); } self.#eventNameToEventLog[event]!(JSON.stringify(eventObj)); // also send as an event return super.emit(event as string, eventObj, ...values); } catch (e: any) { self.emit(self.engineEvents["engine.error"], { msg: "unable to emit events", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); return false; } }; public get numStreams(): number { try { return Object.keys(this.#streamsMap).length; } catch (error) { return 0; } } // External API, where client must wait for engine to be initialized (aka constructor called) public async updateEngineConfigs( receivedEngineConfig: Partial<EngineConfig> = {}, receivedEngineExtraConfig: Partial<EngineExtraConfig> = {} ) { let self = this; let caller = getCaller(); self.#traceLog(`updateEngineConfigs called from: ${caller}`); try { await self.#constructorDone.promise; return await self.#engineUpdateConfigOptionsMutex.runExclusive( async () => { self.#traceLog(`updateEngineConfigs mutex acquired from: ${caller}`); await self.#setEngineConfig(receivedEngineConfig); await self.#setEngineExtraConfig(receivedEngineExtraConfig); } ); } catch (e: any) { self.emit(self.engineEvents["engine.error"], { msg: "unable to update engine config and options", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); } } /** * internal API that sets the engine config. * called from the constructor and updateEngineConfigs method * @param receivedEngineConfig */ async #setEngineConfig(receivedEngineConfig: Partial<EngineConfig>) { let self = this; self.#traceLog(`#setEngineConfig called from: ${getCaller()}`); try { if (self.#isActive) { throw new Error("cannot set engineConfig on an active engine"); } // take care of engineConfig if (this.#engineConfig === undefined) { this.#engineConfig = merge( {}, config.engineConfig, receivedEngineConfig ); // deep merge } else { this.#engineConfig = merge( {}, config.engineConfig, //.defaultEngineConfig, this.#engineConfig, receivedEngineConfig ); } // speical care so `metrics` contains only a type since merge wont work on it if (receivedEngineConfig.metrics) { this.#engineConfig.metrics = receivedEngineConfig.metrics; } // speical care so `tracer` contains only a type since merge wont work on it if (receivedEngineConfig.tracer) { this.#engineConfig.tracer = receivedEngineConfig.tracer; } this.#debugLog( "received engineConfig:\n", JSON.stringify(receivedEngineConfig, null, 0) ); this.#debugLog( "sanitized engineConfig created:\n", JSON.stringify(this.#engineConfig, null, 0) ); this.#scheme = this.#engineConfig.http.cert_file && this.#engineConfig.http.key_file ? "https" : "http"; } catch (e: any) { self.emit(self.engineEvents["engine.warn"], { msg: "unable to update engine config. using defaultEngineConfig", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); // if (this.#engineConfig === undefined) { // this.#engineConfig = config.engineConfig; // as EngineConfig; //config.defaultEngineConfig as EngineConfig; // } } } /** * internal API that sets the engine extraConfig. * called from the constructor and updateEngineConfigs method * @param receivedEngineExtraConfig */ async #setEngineExtraConfig( receivedEngineExtraConfig: Partial<EngineExtraConfig> ) { let self = this; self.#traceLog(`#setEngineExtraConfig called from: ${getCaller()}`); try { if (self.#isActive) { throw new Error("cannot set engineExtraConfig on an active engine"); } // take care of engineExtraConfig if (this.#engineExtraConfig === undefined) { this.#engineExtraConfig = merge( {}, config.engineExtraConfig, receivedEngineExtraConfig ); // deep merge } else { this.#engineExtraConfig = merge( {}, config.engineExtraConfig, //.defaultEngineConfig, this.#engineExtraConfig, receivedEngineExtraConfig ); } // if (receivedEngineExtraConfig === undefined) { // receivedEngineExtraConfig = {}; // } // if (receivedEngineExtraConfig.isLocal === undefined) { // receivedEngineExtraConfig.isLocal = true; // } // if ( // receivedEngineExtraConfig.handleProcessUncaughtException === undefined // ) { // receivedEngineExtraConfig.handleProcessUncaughtException = true; // } // if ( // receivedEngineExtraConfig.handleProcessUnhandledRejection === undefined // ) { // receivedEngineExtraConfig.handleProcessUnhandledRejection = true; // } this.#debugLog( "received engineExtraConfig:\n", JSON.stringify(receivedEngineExtraConfig, null, 0) ); this.#debugLog( "sanitized engineExtraConfig created:\n", JSON.stringify(this.#engineExtraConfig, null, 0) ); // take care of unhandled exceptions if (self.#engineExtraConfig.handleProcessUncaughtException) { process.on("uncaughtException", function (e: any) { self.emit(self.engineEvents["engine.fatal"], { msg: "uncaughtException was received, the engine will attempt to stop gracefully now", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); }); } if (self.#engineExtraConfig.handleProcessUnhandledRejection) { process.on("unhandledRejection", function (e: any) { self.emit(self.engineEvents["engine.fatal"], { msg: "unhandledRejection was received, the engine will attempt to stop gracefully now", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); }); } // apply debugNamespace if (this.#engineExtraConfig.debugNamespace) { let prevNamespaces = debug.disable(); debug.enable( [prevNamespaces, this.#engineExtraConfig.debugNamespace].join(",") ); } } catch (e: any) { self.emit(self.engineEvents["engine.warn"], { msg: "unable to update engine options. keeping defaults", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); } } async #createAxiosInstance() { let self = this; self.#traceLog( `createAxiosInstance constructor called from: ${getCaller()}` ); try { this._axiosInstance = axios.create({ baseURL: `${this.#scheme}://${this.#engineConfig.http.address}`, }); axiosRetry(this._axiosInstance, { retries: 3, retryDelay: axiosRetry.exponentialDelay, onRetry: (retryCount, err, requestConfig) => { this.#debugLog( `retrying (do not panic): ${requestConfig.url}`, JSON.stringify({ retryCount: retryCount, error: err.toJSON() }) ); }, }); } catch (e: any) { self.emit(self.engineEvents["engine.fatal"], { msg: "unable to create axios instance", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); } } /** * start the engine, and the streams already added to it. * for remote engines, it will start the mgmt process e.g. ping, cleanup etc. * @returns */ public async start(): Promise<Engine> { let self = this; let caller = getCaller(); self.#traceLog(`start called from: ${caller}`); try { return await self.#engineConstrStartStopMutex.runExclusive(async () => { self.#traceLog(`start mutex acquired from: ${caller}`); if (this.#isActive) { // make sure engine is started after a mutex is acquired self.#debugLog("engine isActive=true, ignoring the call to start()"); return self; } if (self.#engineExtraConfig.isLocal) { self.#debugLog("isLocal=true"); /** * CHECK #1 checkExeExists */ let benthosTag = "v" + self.#engineExtraConfig.benthosVersion; // "v4.5.1" let benthosVersion = self.#engineExtraConfig.benthosVersion; //"4.5.1" let benthosOS = self.#engineExtraConfig.benthosOS; // || "linux"; let benthosArch = self.#engineExtraConfig.benthosArch; // || "amd64"; // let benthosArm = ""; // we won't use benthosArm, instead benthosArch has the required values concatinated // if benthosFileName is provided following are not used: benthosVersion, benthosOS & benthosArch let benthosFileName = config.engineExtraConfig.benthosFileName || `benthos_${benthosVersion}_${benthosOS}_${benthosArch}`; let benthosDir = config.engineExtraConfig.benthosDir; self.#benthosEXEFullPath = path.join(benthosDir, benthosFileName); let benthosArchiveFullPath = path.join( benthosDir, benthosFileName + ".tar.gz" ); if (!config.engineExtraConfig.benthosFileName) { // benthosFileName was not provided so will download if not present try { await fs.promises.stat(self.#benthosEXEFullPath); self.#debugLog( `${self.#benthosEXEFullPath} exists and will be using it.` ); } catch (e) { self.#debugLog(`${self.#benthosEXEFullPath} doesn't exist`); try { let benthosURL = `https://github.com/benthosdev/benthos/releases/download/${benthosTag}/${ benthosFileName + ".tar.gz" }`; self.#debugLog(`downloading archive from: ${benthosURL}`); let resp: AxiosResponse<any, any>; try { resp = await axios.get(benthosURL, { responseType: "stream", }); } catch (e: any) { throw standardizeAxiosErrors(e); } await streamPromises.pipeline( resp.data, fs.createWriteStream(benthosArchiveFullPath) ); self.#debugLog(`extracting archive ${benthosArchiveFullPath}`); execaCommandSync( `tar xzvf ${benthosArchiveFullPath} -C ${benthosDir} benthos` ); execaCommandSync( `mv ${path.join(benthosDir, "benthos")} ${path.join( benthosDir, benthosFileName )}` ); await fs.promises.chmod(self.#benthosEXEFullPath, "0777"); // fs.chmodSync(self.#benthosEXEFullPath, "0777"); self.#debugLog(`benthos installation completed`); } catch (e: any) { self.emit(self.engineEvents["engine.fatal"], { msg: "benthos cannot be installed", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); // TODO: change to error along with the 3 return self; } } } /** * CHECK #2 benthos version is supported by exthos */ let versionRequiredMinArray = constants.minBenthosVSupported.split("."); let versionRequiredMin: { major: number; minor: number; patch: number; } = { major: parseInt(versionRequiredMinArray[0], 10), minor: parseInt(versionRequiredMinArray[1], 10), patch: parseInt(versionRequiredMinArray[2], 10), }; // let versionPresent: { major: number; minor: number; patch: number }; try { let versionPresentArray = execaCommandSync( `${self.#benthosEXEFullPath} -v` ) .stdout.split(EOL)[0] .split(": ")[1] .split("."); let versionPresent = { major: parseInt(versionPresentArray[0], 10), minor: parseInt(versionPresentArray[1], 10), patch: parseInt(versionPresentArray[2], 10), }; if ( !( versionPresent.major === versionRequiredMin.major && (versionPresent.minor > versionRequiredMin.minor || (versionPresent.minor === versionRequiredMin.minor && versionPresent.patch >= versionRequiredMin.patch)) ) ) { throw new Error( `benthos version ${versionPresentArray.join( "." )} is not supported by exthos. exthos supports MINOR versions greater than equal to ${versionRequiredMinArray.join( "." )}` ); } self.#debugLog( `using benthos version: ${versionPresentArray.join(".")}` ); } catch (e: any) { self.emit(self.engineEvents["engine.fatal"], { msg: `benthos cannot be used`, error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); return self; } /** * CHECK #3 tempLocalServer check */ try { // check if address is local and we can bind to it self.#debugLog( "creating tempLocalServer to establish ADDR, PORT availability" ); self.#tempLocalServer = net.createServer(); let host = self.#engineConfig.http.address.split(":")[0]; let port = self.#engineConfig.http.address.split(":")[1]; // _tempLocalServer listening event let isListeningDeferred = new Deferred(); self.#tempLocalServer.on("listening", () => { isListeningDeferred.resolve("deferred_listening"); self.#debugLog("tempLocalServer is listening"); }); // _tempLocalServer error event // let _tempLocalServerError let isErrorDeferred = new Deferred(); // hack: using this because `once(self._tempLocalServer, "error")` is not working with Promise.race self.#tempLocalServer.on("error", (e: any) => { if (!e) { isErrorDeferred.resolve(); } else { self.#debugLog("tempLocalServer errored out", e); isErrorDeferred.reject(e); } }); let raceProm = Promise.race([ isListeningDeferred.promise, isErrorDeferred.promise, new Promise((_, rj) => { setTimeout(() => { rj("tempLocalServer timedout"); }, 2000); }), ]); // _tempLocalServer.listen self.#tempLocalServer.listen(parseInt(port, 10), host, () => {}); await raceProm; // _tempLocalServer.close let isCloseErrorDeferred = new Deferred(); self.#tempLocalServer.close((e: any) => { if (!e) { self.#tempLocalServer.unref(); isCloseErrorDeferred.resolve(); } else { self.emit(self.engineEvents["engine.fatal"], { msg: "unable to close tempLocalServer", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); isCloseErrorDeferred.reject(e); } }); await isCloseErrorDeferred.promise; self.#debugLog( "tempLocalServer closed, i.e. listening=", self.#tempLocalServer.listening ); } catch (e: any) { self.emit(self.engineEvents["engine.fatal"], { msg: "unable to start tempLocalServer", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); return self; } // write the engine config at this point Engine.#writeToEngineConfigFilePath.call(self); // self._engineProcess = execaCommand(`benthos -c ${self._engineConfigFilePath} streams`, { self.#engineProcess = execaCommand( `${self.#benthosEXEFullPath} -w -c ${ self.#engineConfigFilePath } streams`, { signal: self.#abortController.signal, buffer: false, detached: true, // so that SIGINT on parent doesnt not reach the child as well. detached => child is a diff process group // https://nodejs.org/api/child_process.html#child_process_options_detached } ); process.stdin.pipe(self.#engineProcess.stdin!); process.on("SIGINT", () => { self.stop("SIGINT was received"); }); let loggerWritable = new stream.Writable({ write: function (chunk, _, next) { // delay the log lines by 1 sec. if we dont do this, events are generated for streams even before they get added to the streamsMap setTimeout(() => { try { // chunk can contain multiple json log lines chunk .toString() .trim() .split("\n") .forEach((str: string) => { // hack: sometimes benthos sends null so skip it if (str !== "null") { // let j: { level: string, stream?: any, msg: string, time: string } = { level: "", msg: "", time: getISOStringLocalTz() } let j: Omit<EventObj, "stream"> & { stream?: any } = { level: "", msg: "", time: getISOStringLocalTz(), }; try { // parse and add stream object instead of just the ID j = JSON.parse(str); j.stream = self.#streamsMap[j.stream] ? self.#streamsMap[j.stream] : j.stream; str = JSON.stringify(j); //TODO: check if j contains "stream", if not then events below should change to engine.info etc. } catch (error) { // do nothing, so it will fall into switch.default } switch (j.level) { case "off": case "none": break; case "fatal": j.error = { message: j.msg }; self.emit( self.engineEvents["engineProcess.stream.fatal"], j ); break; case "error": j.error = { message: j.msg }; self.emit( self.engineEvents["engineProcess.stream.error"], j ); break; case "warn": case "warning": j.error = { message: j.msg }; self.emit( self.engineEvents["engineProcess.stream.warn"], j ); break; case "info": self.emit( self.engineEvents["engineProcess.stream.info"], j ); break; case "debug": self.emit( self.engineEvents["engineProcess.stream.debug"], j ); break; case "trace": self.emit( self.engineEvents["engineProcess.stream.trace"], j ); break; default: // isnt a log line. v likely an output.stdout/err // case "all" goes here console.log(str); break; } } }); } catch (e: any) { self.emit(self.engineEvents["engine.error"], { msg: "unable to write engineProcess events into loggerWritable", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); } }, 0); // 1000); next(); }, }); self.#engineProcess.stdout?.pipe(loggerWritable); self.#engineProcess.stderr?.pipe(loggerWritable); self.#engineProcess.catch((e) => { if (e.killed && e.isCanceled) { // abort was used self.emit(self.engineEvents["engine.inactive"], { msg: "aborted successfully", time: getISOStringLocalTz(), }); } else if (e.all) { self.emit(self.engineEvents["engine.fatal"], { msg: "engineProcess exited unexpectedly (1)", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); } else { self.emit(self.engineEvents["engine.fatal"], { msg: "engineProcess exited unexpectedly (2)", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); } }); // wait for the engineProcess to spawn await once(self.#engineProcess, "spawn"); // wait for ping to work after spawn, only then mark active and start mgmt events try { await self._apiGetPing({ "axios-retry": { retries: 3 } }); self.emit(self.engineEvents["engine.active"], { msg: "engineProcess=isLocal. first ping pass & marked active", time: getISOStringLocalTz(), }); } catch (e) { self.emit(self.engineEvents["engine.fatal"], { msg: "engineProcess=isLocal. first ping failed", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); return self; } // self._startMgmtEvents() } else { // remote servier, so nothing to self.#debugLog("isLocal=false"); try { await self._apiGetPing({ "axios-retry": { retries: 3 } }); self.emit(self.engineEvents["engine.active"], { msg: "engineProcess<>isLocal. first ping pass & marked active", time: getISOStringLocalTz(), }); } catch (e) { self.emit(self.engineEvents["engine.fatal"], { msg: "engineProcess<>isLocal. first ping failed", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); return self; } // self._startMgmtEvents() } if (self.numStreams > 0) { await self.add(...Object.values(self.#streamsMap)); } self.#startMgmtEvents(); self.#debugLog( "waiting for event=engine.active before finish of start" ); await EventEmitter2.once(self, self.engineEvents["engine.active"]); // start the _keepAliveInterval self.#keepAliveInterval = setInterval(() => {}, 1 << 30); return self; }); } catch (e: any) { self.emit(self.engineEvents["engine.fatal"], { msg: "start failed", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); return self; } } /** * stop the engine and all its streams * TODO: verify that stream closes gracefully else revert to execa kill("SIGTERM") */ async stop(): Promise<Engine>; async stop(reason: string): Promise<Engine>; async stop(reason: string, force: boolean): Promise<Engine>; async stop(reason?: string, force?: boolean): Promise<Engine> { if (reason === undefined) { reason = ""; } if (force === undefined) { force = false; } let self = this; let caller = getCaller(); self.#traceLog(`stop called from: ${caller}`); try { return await self.#engineConstrStartStopMutex.runExclusive(async () => { self.#traceLog(`stop mutex acquired from: ${caller}`); // if engine is not active, wait for it if (!self.#isActive) { self.#debugLog( `waiting for event=engine.active for ${self.waitForActiveEventMs} seconds before stopping` ); try { await self.waitFor( self.engineEvents["engine.active"], self.waitForActiveEventMs ); } catch (e) { self.#debugLog("engine isActive=false, skipping stopping"); clearInterval(self.#keepAliveInterval); return self; } } // is active at this point // remove all streams first self.#debugLog("removing all streams before stopping"); await self.remove(); if (self.#engineExtraConfig.isLocal) { // remove the engine config file await fs.promises.unlink(self.#engineConfigFilePath); // fs.unlinkSync(self.#engineConfigFilePath); if (force) { self.#abortController.abort(); } else { // using SIGTERM (or SIGNIT [benthos behaves the same]) instead of abort signal self.#engineProcess.kill("SIGTERM", { forceKillAfterTimeout: parseInt(self.#engineConfig.shutdown_timeout, 10) + 1, // 1 second of extra buffer time }); } } // perform regardless of local or not clearInterval(self.#keepAliveInterval); self.emit(self.engineEvents["engine.inactive"], { msg: `stopped successfully` + (reason ? ". reason:" + reason : ""), time: getISOStringLocalTz(), }); return self; }); } catch (e: any) { self.emit(self.engineEvents["engine.fatal"], { msg: "stop failed. performing process.exit", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); process.exit(1); } } async #startMgmtEvents() { let self = this; let caller = getCaller(); self.#traceLog(`_startMgmtEvents called from: ${caller}`); let shutDownTimer: NodeJS.Timer; do { try { if (!self.#isActive) { self.#debugLog("engine is not active. existing mgmt event loop"); if (shutDownTimer!) { clearTimeout(shutDownTimer!); // to clearTimout if a stream existed since the timeout was started shutDownTimer!.unref(); } break; } // shutdown if no streams running for if (!self.#engineExtraConfig.keepAlive) { if ( self.numStreams === 0 && !(shutDownTimer! !== undefined && shutDownTimer!.hasRef()) ) { // schedule to stop engine after n seconds ONLY if numStreams is till 0 self.#debugLog( `engine.stop will be called if no streams exist for the next ${ self.#engineExtraConfig.shutdownAfterInactivityForMs }ms` ); shutDownTimer = setTimeout(() => { if (self.numStreams === 0) { self.stop( `no streams for the last ${ self.#engineExtraConfig.shutdownAfterInactivityForMs }ms` ); } }, self.#engineExtraConfig.shutdownAfterInactivityForMs); } else if ( self.numStreams > 0 && shutDownTimer! !== undefined && shutDownTimer!.hasRef() ) { clearTimeout(shutDownTimer!); // to clearTimout if a stream existed since the timeout was started shutDownTimer!.unref(); } } /** * ping */ // if engine is not active, wait for it // if (!self._isActive) { // self._debug("waiting for event=engine.active before ping") // await EventEmitter2.once(self, self.engineEvents["engine.active"]) // } try { await self._apiGetPing({ "axios-retry": { retries: 0 } }); } catch (e) { self.emit(self.engineEvents["engine.inactive"], { msg: "ping failed", time: getISOStringLocalTz(), }); throw e; } self.emit(self.engineEvents["engine.active"], { msg: "ping success", time: getISOStringLocalTz(), }); /** * stream cleanup */ for (let stream of Object.values(self.#streamsMap)) { // if engine is not active, wait for it if (!self.#isActive) { self.#debugLog("waiting for event=engine.active before cleanup"); await EventEmitter2.once(self, self.engineEvents["engine.active"]); } let resp = await self._apiGetStreamReady(stream, { validateStatus: (status) => { return status < 400 || status === 503; }, }); if (resp.status === 503) { // cleanup // self.emit(self.engineEvents["engineProcess.stream.fatal"], { msg: "stream status is not ready", error: formatErrorForEvent(new Error(resp.data || "reason unknown")), stream, time: getISOStringLocalTz() }) let msg = `stream status equals/changed to not-ready: ${ resp.data || "reason unknown" }. removing stream`; self.emit(self.engineEvents["engineProcess.stream.warn"], { msg, stream, error: formatErrorForEvent(new Error(msg)), time: getISOStringLocalTz(), }); self.remove(msg, stream); } } } catch (e: any) { self.emit(self.engineEvents["engine.error"], { msg: "unable to start mgmt events", error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); } } while (await sleep(self.#mgmtEventsFreqMs)); //2000 } /** * adds one or more streams to the engine * @param stream */ public async add(...streams: Stream[]): Promise<Engine> { let self = this; let caller = getCaller(); self.#traceLog(`add called from: ${caller}`); try { return await self.#engineStreamAddUpdateRemoveMutex.runExclusive( async () => { self.#traceLog(`add mutex acquired from: ${caller}`); self.#debugLog( `add called for streams: ${streams.map((s) => s.streamID)}` ); if (!self.#isActive) { self.#debugLog( `waiting for event=engine.active for ${self.waitForActiveEventMs} seconds before adding` ); try { await self.waitFor( self.engineEvents["engine.active"], self.waitForActiveEventMs ); } catch (e) { self.#debugLog( "engine isActive=false, skipping adding/_apiPostStream" ); return self; } } for (let stream of streams) { try { if (stream.hasInport) { stream.createInport(); } if (stream.hasOutport) { stream.createOutport(); } // best practice await stream.beforeAdd(); await self._apiPostStream(stream); self.#streamsMap[stream.streamID] = stream; this.emit(self.engineEvents["engine.stream.add"], { msg: `stream added to engine`, stream, time: getISOStringLocalTz(), }); } catch (e: any) { self.emit(self.engineEvents["engine.stream.error"], { msg: `stream add to engine failed`, error: formatErrorForEvent(e), stream, time: getISOStringLocalTz(), }); } } return self; } ); } catch (e: any) { self.emit(self.engineEvents["engine.error"], { msg: `engine unable to add any streams`, error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); return self; } } /** * updates one or more existing streams on the engine * @param stream * @returns */ public async update(...streams: Stream[]): Promise<Engine> { let self = this; let caller = getCaller(); self.#traceLog(`update called from: ${caller}`); try { return await self.#engineStreamAddUpdateRemoveMutex.runExclusive( async () => { self.#traceLog(`update mutex acquired from: ${caller}`); self.#debugLog( `update called for streams: ${streams.map((s) => s.streamID)}` ); if (!self.#isActive) { self.#debugLog( `waiting for event=engine.active for ${self.waitForActiveEventMs} seconds before updating` ); try { await self.waitFor( self.engineEvents["engine.active"], self.waitForActiveEventMs ); } catch (e) { self.#debugLog( "engine isActive=false, skipping update/_apiPutStream" ); return self; } } for (let stream of streams) { try { await self._apiPutStream(stream); self.#streamsMap[stream.streamID] = stream; this.emit(self.engineEvents["engine.stream.update"], { msg: `stream updated to engine`, stream, time: getISOStringLocalTz(), }); } catch (e: any) { self.emit(self.engineEvents["engine.stream.error"], { msg: "stream update to engine failed", error: formatErrorForEvent(e), stream, time: getISOStringLocalTz(), }); } } return self; } ); } catch (e: any) { self.emit(self.engineEvents["engine.error"], { msg: `engine unable to update any streams`, error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); return self; } } /** * removes a stream from the engine * no parameters => all added streams will be removed */ public async remove(): Promise<Engine>; public async remove(...streams: Stream[]): Promise<Engine>; public async remove(reason: string, ...streams: Stream[]): Promise<Engine>; public async remove(...streamsWWOReason: any[]): Promise<Engine> { let reason: string = ""; let streams: Stream[]; if (typeof streamsWWOReason[0] === "string") { reason = streamsWWOReason[0]; streams = streamsWWOReason.slice(1); } else { streams = streamsWWOReason; } let self = this; let caller = getCaller(); self.#traceLog(`remove called from: ${caller}`); try { return await self.#engineStreamAddUpdateRemoveMutex.runExclusive( async () => { self.#traceLog(`remove mutex acquired from: ${caller}`); if (streams.length === 0) { streams = Object.values(this.#streamsMap); } self.#debugLog( `remove called for streams: ${streams.map((s) => s.streamID)}` ); if (!self.#isActive) { self.#debugLog( `waiting for event=engine.active for ${self.waitForActiveEventMs} seconds before removing` ); try { await self.waitFor( self.engineEvents["engine.active"], self.waitForActiveEventMs ); } catch (e) { self.#debugLog( "engine isActive=false, skipping removing/_apiDeleteStream" ); return self; } } if (streams.length === 0) { self.#debugLog("no stream to remove"); } for (let stream of streams) { try { if (!self.#streamsMap[stream.streamID]) { self.#debugLog( `stream [ID=${stream.streamID}] not present in engine streamMap. possibly already removed` ); continue; } // best practice await stream.afterRemove(); await self._apiDeleteStream(stream); // if hasOutProc close it, to clean the sock // i.e. close actually removes the .sock file if (stream.hasOutport) { stream.outport.close(); } if (stream.hasInport) { stream.inport.close(); } delete self.#streamsMap[stream.streamID]; self.emit(self.engineEvents["engine.stream.remove"], { msg: `stream removed from engine ${ reason ? "reason:" + reason : "" }`, stream, time: getISOStringLocalTz(), }); } catch (e: any) { self.emit(self.engineEvents["engine.stream.error"], { msg: "stream remove from engine failed", error: formatErrorForEvent(e), stream, time: getISOStringLocalTz(), }); } } return self; } ); } catch (e: any) { self.emit(self.engineEvents["engine.error"], { msg: `engine unable to remove any streams`, error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); return self; } } static #writeToEngineConfigFilePath(this: Engine) { let self = this; let caller = getCaller(); self.#traceLog(`writeToEngineConfigFilePath called from: ${caller}`); try { fs.writeFileSync( this.#engineConfigFilePath, JSON.stringify(this.#engineConfig) ); self.#debugLog( `engine config successfully written to: ${this.#engineConfigFilePath}` ); } catch (e) { let msg = `failed to write engine config into ${ this.#engineConfigFilePath }}`; self.emit(self.engineEvents["engine.fatal"], { msg, error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); } } /** * The default event hander prints all events on the console/stdout. Additionally, it: * - stops the stream on receiving an "engineProcess.stream.error" event 5 times * @param this Engine * @param eventName the name of the event * @param eventObj the event object of type EventObj containing event information */ static #defaultEngineEventHandler = (function () { let streamErrorCounter: { [streamID: string]: any } = {}; // streamID to count return function ( this: Engine, eventName: string | string[], eventObj: EventObj ) { let self = this; try { if (eventName === "engineProcess.stream.error") { let streamID: string; if (eventObj.stream && eventObj.stream.streamID) { streamID = eventObj.stream.streamID; } else { streamID = "unknown"; } console.log(`<event>${eventName}>${JSON.stringify(eventObj)}`); streamErrorCounter[streamID] = streamErrorCounter[streamID] || 0; streamErrorCounter[streamID] = streamErrorCounter[streamID] + 1; if (streamErrorCounter[streamID] === 5 && eventObj.stream) { // remove the stream if error received 5 times self.remove(eventObj.stream); } } else { console.log(`<event>${eventName}>${JSON.stringify(eventObj)}`); } } catch (e: any) { self.emit(self.engineEvents["engine.error"], { msg: `defaultEngineEventHandler unable to handle events`, error: formatErrorForEvent(e), time: getISOStringLocalTz(), }); } }; })(); /** * use the default event handler, and optinally provide additional event handlers for custom logic * @param addnEventHandlers */ public useDefaultEventHandler( addnEventHandlers: { [eventName: string]: ListenerFn } = {} ) { let self = this; self.onAny(Engine.#defaultEngineEventHandler.bind(self)); Object.keys(addnEventHandlers).forEach((eventName) => { self.on(eventName, addnEventHandlers[eventName]); }); } } export { Engine };